Fire Red
Fire Red

Reputation: 45

How to Improve Html5 Canvas Performance

So I have this project I have been working on and the goal of it is to randomly generate terrain on a 2D plane, and put rain in the background, and I chose to use the html5 canvas element to accomplish this goal. After creating it I am happy with the result but I am having performance issues and could use some advice on how to fix it. So far I have tried to only clear the bit of the canvas that is needed, which is above the rectangles I drew under the terrain to fill it in, but because of this I have to redraw the circles. The rn(rain number) has already been lowered by about 2 times and it still lags, any suggestions?

Note - The code in the snippet does not lag due to it's small size, but if I was to run it in full screen with the actual rain number(800), it would lag. I have shrunk the values to fit the snippet.

var canvas = document.getElementById('gamecanvas');
var c = canvas.getContext('2d');

var ma = Math.random;
var mo = Math.round;

var wind = 5;

var rn = 100;
var rp = [];

var tp = [];
var tn;

function setup() {
    
    //fillstyle
    c.fillStyle = 'black';

    //canvas size
    canvas.height = window.innerHeight;
    canvas.width = window.innerWidth;

    //rain setup
    for (i = 0; i < rn; i++) {
        let x = mo(ma() * canvas.width);
        let y = mo(ma() * canvas.width);
        let w = mo(ma() * 1) + 1;
        let s = mo(ma() * 5) + 10;
        rp[i] = { x, y, w, s };
    }

    //terrain setup
    tn = (canvas.width) + 20;
    tp[0] = { x: -2, y: canvas.height - 50 };
    for (i = 1; i <= tn; i++) {
        let x = tp[i - 1].x + 2;
        let y = tp[i - 1].y + (ma() * 20) - 10;
        if (y > canvas.height - 50) {
            y = tp[i - 1].y -= 1;
        }
        if (y < canvas.height - 100) {
            y = tp[i - 1].y += 1;
        }
        tp[i] = { x, y };
        c.fillRect(x, y, 4, canvas.height - y);
    }
}

function gameloop() {

    //clearing canvas
    for (i = 0; i < tn; i++) {
        c.clearRect(tp[i].x - 2, 0, 2, tp[i].y);
    }

    for (i = 0; i < rn; i++) {

        //rain looping
        if (rp[i].y > canvas.height + 5) {
            rp[i].y = -5;
        }
        if (rp[i].x > canvas.width + 5) {
            rp[i].x = -5;
        }

        //rain movement
        rp[i].y += rp[i].s;
        rp[i].x += wind;

        //rain drawing
        c.fillRect(rp[i].x, rp[i].y, rp[i].w, 6);
    }

    for (i = 0; i < tn; i++) {

        //terrain drawing
        c.beginPath();
        c.arc(tp[i].x, tp[i].y, 6, 0, 7);
        c.fill();
    }
}

setup();
setInterval(gameloop, 1000 / 60);
body {
    background-color: white;
    overflow: hidden;
    margin: 0;
}
canvas {
    background-color: white;
}
<html>
<head>
    <link rel="stylesheet" href="index.css">
    <title>A Snowy Night</title>
</head>
<body id="body"> <canvas id="gamecanvas"></canvas>
    <script src="index.js"></script>
</body>
</html>

Upvotes: 3

Views: 1840

Answers (2)

Wax
Wax

Reputation: 625

Superimposing canvas

Like I suggested in my comment, the use of a second canvas point is to only have to draw the terrain once, and hence it could enhance the performance of your animation by saving a redraw on each new frame. This can be done with CSS by positioning one on the other (like layers).

#canvasBase {
  position: relative;
}

#canvasLayer1 {
  position: absolute;
  top: 0;
  left: 0;
}

#canvasLayer2 {
  position: absolute;
  top: 0;
  left: 0;
}

// etc...

Also I advise you to use requestAnimationFrame over setinterval (see why).

requestAnimationFrame

However, by using requestAnimationFrame, we don't control the refresh rate, it's tied to the client hardware. So we need to handle it and for that, we will use the DOMHighResTimeStamp which is passed as an argument to our callback method.

The idea is to let it run at native speed and manage the fps by updating the logic (our calculs) only at desired time. For exemple, if we need a fps = 60; that means we need to update our logic every 1000 / 60 = ~16,67 ms. So we check if the deltaTime with the time of the last frame is equal or superior than ~16,67ms. If not enough time elapsed, we call a new frame & we return (important, otherwise the control we just did is useless as the code keeps going whatever the outcome of it).

let fps = 60;

/* Check if we need to update the logic */
/* if not request a new frame & return */

if(deltaLastUpdate <= 1000 / fps){ // 1000 / 60 = ~16,67ms
  requestAnimationFrame(animate);
  return;
}

Clearing canvas

As you need to erase all the past rain drops, the simplest & cheapest in ressources in to clear the whole context in one swoop.

ctxRain.clearRect(0, 0, rainCanvas.width, rainCanvas.height);

Path2D

As your drawing use the same color for the rain drops, you can as well group all these in one path:

rainPath = new Path2D();
...

So you will need only one instruction to draw them (same ressources saving type as the clearRect):

ctxRain.fill(rainPath);

Result

/* CANVAS "Terrain" */
const terrainCanvas = document.getElementById('gameTerrain');
const ctxTerrain = terrainCanvas.getContext('2d');
terrainCanvas.height = window.innerHeight;
terrainCanvas.width = window.innerWidth;

/*  CANVAS "Rain" */
const rainCanvas = document.getElementById('gameRain');
const ctxRain = rainCanvas.getContext('2d');
rainCanvas.height = window.innerHeight;
rainCanvas.width = window.innerWidth;

/* Game Constants */
const wind = 5;
const rainMaxParticules = 100;
const rain = [];
let rainPath;
const terrainMaxParticules = terrainCanvas.width + 20;
const terrain = [];
let terrainPath;

/* Maths help */
const ma = Math.random;
const mo = Math.round;

/* Clear */
function clearTerrain(){
    ctxTerrain.clearRect(0, 0, terrainCanvas.width, terrainCanvas.height);
}
function clearRain(){
    ctxRain.clearRect(0, 0, rainCanvas.width, rainCanvas.height);
}

/* Logic */
function initTerrain(){
    terrain[0] = { x: -2, y: terrainCanvas.height - 50 };
    for (let i = 1; i <= terrainMaxParticules; i++) {
        let x = terrain[i - 1].x + 2;
        let y = terrain[i - 1].y + (ma() * 20) - 10;
        if (y > terrainCanvas.height - 50) {
            y = terrain[i - 1].y -= 1;
        }
        if (y < terrainCanvas.height - 100) {
            y = terrain[i - 1].y += 1;
        }
        terrain[i] = { x, y };
    }
}

function initRain(){
    for (let i = 0; i < rainMaxParticules; i++) {
        let x = mo(ma() * rainCanvas.width);
        let y = mo(ma() * rainCanvas.width);
        let w = mo(ma() * 1) + 1;
        let s = mo(ma() * 5) + 10;
        rain[i] = { x, y, w, s };
    }
}

function init(){
  initTerrain();
  initRain();
}

function updateTerrain(){
    terrainPath = new Path2D();
    for(let i = 0; i < terrain.length; i++){
      terrainPath.arc(terrain[i].x, terrain[i].y, 6, Math.PI/2, 5*Math.PI/2);
    }
    terrainPath.lineTo(terrainCanvas.width, terrainCanvas.height);
    terrainPath.lineTo(0, terrainCanvas.height);
}

function updateRain(){
    rainPath = new Path2D();
    for (let i = 0; i < rain.length; i++) {
      // Rain looping
      if (rain[i].y > rainCanvas.height + 5) {
        rain[i].y = -5;
      }
      if (rain[i].x > rainCanvas.width + 5) {
        rain[i].x = -5;
      }
      // Rain movement
      rain[i].y += rain[i].s;
      rain[i].x += wind;
      
      // Path containing all the drops
      rainPath.rect(rain[i].x, rain[i].y, rain[i].w, 6);
    }
}

/* Drawing */
function drawTerrain(){
    ctxTerrain.fillStyle = 'black';
    ctxTerrain.fill(terrainPath);
}

function drawRain(){
    ctxRain.fillStyle = 'black';    
    ctxRain.fill(rainPath);
}

/* Animation Constant */
const fps = 60;
let lastTimestampUpdate;
let terrainDrawn = false;

/*  Game loop */
function animate(timestamp){

  /* Initialize rain & terrain particules */
  if(rain.length === 0 || terrain.length === 0){
    init();
  }

  /* Define "lastTimestampUpdate" from the first call */
  if (lastTimestampUpdate === undefined){
    lastTimestampUpdate = timestamp;
  }

  /* Check if we need to update the logic & the drawing, if not, request a new frame & return */
  if(timestamp - lastTimestampUpdate <= 1000 / fps){
    requestAnimationFrame(animate);
    return;
  }

  if(!terrainDrawn){
  /* Terrain --------------------- */
  /* Clear */
  clearTerrain();
  /* Logic */  
  updateTerrain();
  /* Draw */
  drawTerrain();
  /* ----------------------------- */
    terrainDrawn = true;
  }
  
  /* --- Rain -------------------- */
  /* Clear */
  clearRain();
  /* Logic  */ 
  updateRain();
  /* Draw */
  drawRain();
  /* ----------------------------- */
    
  /*  Request another frame */
  lastTimestampUpdate = timestamp;
  requestAnimationFrame(animate);

}

/*  Start the animation */
requestAnimationFrame(animate);
body {
    background-color: white;
    overflow: hidden;
    margin: 0;
}

#gameTerrain {
  position: relative;
}

#gameRain {
  position: absolute;
  top: 0;
  left: 0;
}
<body>  
  <canvas id="gameTerrain"></canvas>
  <canvas id="gameRain"></canvas>
</body>


Aside

This won't affect performance, however I encourage you to use const & let over var (What's the difference between using “let” and “var”?).


Upvotes: 2

Kaiido
Kaiido

Reputation: 136598

Generally, having more paint instructions will be what costs the most, the complexity of these paint instructions only comes to play when it's really complex.

Here you are spamming the GPU with paint instructions:

  • (canvas.width) + 20 calls to clearRect(). clearRect() is a paint instruction, and not a cheap one. Use it sporadically, but actually, you should use it only to clear the whole context.
  • One fillRect() per rain drop.. They're all the same color, they can be merged in a single sub-path and drawn in a single draw call.
  • One fill per circle composing the terrain.

So instead of this huge number of draw calls, we could make it in only two draw calls:

One clearRect, one fill() of one big subpath containing both the drops and the terrain.

However it's certainly more practical to keep the terrain and the rain separated, so let's make it three draw calls, by keeping the terrain in its own Path2D object, which is more friendly for the CPU:

var canvas = document.getElementById('gamecanvas');
var c = canvas.getContext('2d');

var ma = Math.random;
var mo = Math.round;

var wind = 5;

var rn = 100;
var rp = [];

// this will hold our Path2D object
// which will hold the full terrain drawing
// set a 'let' because we will set it again on resize
let terrain;
var tp = [];
var tn;

function setup() {
    
    //fillstyle
    c.fillStyle = 'black';

    //canvas size
    canvas.height = window.innerHeight;
    canvas.width = window.innerWidth;

    //rain setup
    for (let i = 0; i < rn; i++) {
        let x = mo(ma() * canvas.width);
        let y = mo(ma() * canvas.width);
        let w = mo(ma() * 1) + 1;
        let s = mo(ma() * 5) + 10;
        rp[i] = { x, y, w, s };
    }

    //terrain setup
    tn = (canvas.width) + 20;
    tp[0] = { x: -2, y: canvas.height - 50 };

    terrain = new Path2D();
    for (let i = 1; i <= tn; i++) {
        let x = tp[i - 1].x + 2;
        let y = tp[i - 1].y + (ma() * 20) - 10;
        if (y > canvas.height - 50) {
            y = tp[i - 1].y -= 1;
        }
        if (y < canvas.height - 100) {
            y = tp[i - 1].y += 1;
        }
        tp[i] = { x, y };
        terrain.rect(x, y, 4, canvas.height - y);
        terrain.arc(x, y, 6, 0, Math.PI*2);
    }

}

function gameloop() {

    // clear the whole canvas
    c.clearRect(0, 0, canvas.width, canvas.height);

    // start a new sub-path for the rain
    c.beginPath();
    for (let i = 0; i < rn; i++) {

        //rain looping
        if (rp[i].y > canvas.height + 5) {
            rp[i].y = -5;
        }
        if (rp[i].x > canvas.width + 5) {
            rp[i].x = -5;
        }

        //rain movement
        rp[i].y += rp[i].s;
        rp[i].x += wind;

        //rain tracing
        c.rect(rp[i].x, rp[i].y, rp[i].w, 6);
    }
    // paint all the drops in a single op
    c.fill();
    // paint the whole terrain in a single op
    c.fill(terrain);

    // loop at screen refresh frequency
    requestAnimationFrame(gameloop);
}

setup();
requestAnimationFrame(gameloop);

onresize = () => setup();
body {
  background-color: white;
  overflow: hidden;
  margin: 0;
}
canvas {
  background-color: white;
}
<canvas id="gamecanvas"></canvas>

Further possible improvements:

  • Instead of making our terrain path a set of rectangles, using only lineTo to trace the actual outline would probably help a bit, some more calculations at init, but it's done only once in a while.

  • If the terrain becomes more complex, with more details, or with various colors and shadows etc. then consider painting it only once, and then produce an ImageBitmap from the canvas. Then in gameLoop you'll just have to drawImage that ImageBitmap (drawing bitmaps is super fast, but storing it consumes memory, so remember to .close() the ImageBitmap when you don't need it anymore).

Upvotes: 3

Related Questions