Modifying in memory canvases before drawing to screen drastically reduces Javascript performance

Viewed 134

I've noticed if I have a large number of canvases in memory, modifying each canvas before drawing them to the screen drastically reduces performance on my machine. This occurs even when the canvases are small and the modifications are minor.

Here is the most contrived example I could come up with:

var { canvas, ctx } = generateCanvas();
ctx.strokeStyle = "#000";

var images = [];
for (var i = 0; i < 500; i++) {
  images.push(generateCanvas(50, "red"));
}

var fps = 0,
  lastFps = new Date().getTime();
requestAnimationFrame(draw);

function draw() {
  requestAnimationFrame(draw);

  var modRects = document.getElementById("mod-rects").checked;
  var drawRects = document.getElementById("draw-rects").checked;

  ctx.clearRect(0, 0, 500, 500);
  ctx.strokeRect(0, 0, 500, 500);

  fps++;
  if (new Date().getTime() - lastFps > 1000) {
    console.clear();
    console.log(fps);
    fps = 0;
    lastFps = new Date().getTime();
  }

  images.forEach(img => {
    img.ctx.fillStyle = "yellow";
    if (modRects) img.ctx.fillRect(20, 20, 10, 10);
    if (drawRects) ctx.drawImage(img.canvas, 225, 225);
  });
}

function generateCanvas(size = 500, color = "black") {
  var canvas = document.createElement("canvas");
  canvas.width = canvas.height = size;
  var ctx = canvas.getContext("2d");
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, size, size);

  return {
    canvas,
    ctx
  };
}

function generateCheckbox(name) {
  var div = document.createElement("div");
  var check = document.createElement("input");
  check.type = "checkbox";
  check.id = name;
  var label = document.createElement("label");
  label.for = name;
  label.innerHTML = name;
  div.appendChild(check);
  div.appendChild(label);
  return div;
}

document.body.appendChild(canvas);
document.body.appendChild(generateCheckbox("mod-rects"));
document.body.appendChild(generateCheckbox("draw-rects"));
canvas+div+div { margin-bottom: 20px; }

In this example we create 500 canvases of size 50x50. There are two checkboxes underneath the larger onscreen canvas. The first causes a small yellow square to be drawn on each of those 500 canvases. The 2nd causes the canvases to be drawn to the larger canvas. FPS is posted to the console once per second. I see no performance issues when one or the other checkbox is checked, but when both are checked, performance drops drastically.

My first thought is that it has something to do with sending in-memory canvas to the gfx card every frame when they are modified.

Here's the actual effect I'm trying to create. image

Video: https://youtu.be/Vr6v2oF3G-8

Code: https://github.com/awhipple/base-command-dev/blob/e2c38946cdaf573abff5ded5399c90687ffa76a5/engine/gfx/shapes/Particle.js

My ultimate goal is to be able to smoothly transition the colors of the canvas. I'm using globalCompositeOperation = "source-in" and fillRect() to do this in the code link above.

2 Answers

As has been stated before, this is an issue with the overhead of sending hundreds of canvases to the GPU every single frame. When a canvas is modified in CPU it gets marked as "dirty" and is re sent to the GPU next time it's used.

The workaround I found was to create a large canvas containing a grid of my particle images. Every particle object makes its modification to its assigned section of the grid. Then once all modifications are made, we begin making draw image calls, cutting up the larger canvas as needed

I also needed to switch to globalCompositeOperation = "source-atop" to prevent all other particles from getting trashed each time I tried to change one.

Code: https://github.com/awhipple/base-command-dev/blob/2514327c6c30cb9914962d2c8d604f04bfbdbed5/engine/gfx/shapes/Particle.js

Examples: http://avocado.whipple.life/

You can see here, when this.newRender === true in draw, it queues up to be drawn later. Then static drawQueuedParticles is called once every particle has had a chance to queue itself up.

The end result is that this larger canvas is only sent to the GPU once per frame. I saw a performance increase from 15 FPS to 60 FPS on my Razorblade Pro running a 2700 RTX GPU with 1500 on screen particles.

I expect browsers are optimized to display 1, or at most a few canvases at a time. I'm betting each canvas is uploaded to the GPU individually, which would have way more overhead than a single canvas. The GPU has a limited number of resources, and using a lot of canvases could cause a lot of churn if textures and buffers are repeatedly cleared for each canvas. This answer WebGL VS Canvas 2D hardware acceleration also claims that Chrome didn't hardware accelerate canvases under 256px.

Since you're trying to do a particle effect with sprites, you'd be better off using a webgl library that's built for this kind of thing. I've had a good experience with https://www.pixijs.com/. If you're doing 3d, https://threejs.org/ is also popular. It is possible to build your own webgl engine, but it's very complicated and a lot of work. You have to worry about things like vector math, vertex buffers, supporting mobile GPU's, batching draw calls, etc. You'd be better off using an existing library unless you really have a strong need for something unique.

Related