4

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.

Argatron
  • 41
  • 4
  • 2
    It's likely that compositing the smaller images onto the larger one is done in GPU, but drawing to the canvases is done in CPU. Transferring the thus dirty images to GPU memory would be slow... – AKX Aug 09 '20 at 15:07
  • 1
    I think @AKX nailed it. Sounds very similar to https://stackoverflow.com/questions/63138513/ Note that only Chrome seems to show this behavior. And for your code in the github repo, only very fast read it for now, but don't call getImageData multiple times per frame, call it only once with the full canvas, and read multiple pixels from that single ImageData. Also, it might force you to do a big rewrite, but it's better to batch similar drawing calls (i.e the ones with the same color styles) in a single path and call only once `fill()` rather than several `fillStyle=color;fillRect()`. – Kaiido Aug 09 '20 at 15:23
  • 1
    And if you really need the performance, go WebGL... – AKX Aug 09 '20 at 15:30
  • I'll go check out that other question. Thanks for the info! Note: I'm only calling getImageData one time. Notice how I cache `generateParticle.particle` and return from the method early if it's already been generated. – Argatron Aug 09 '20 at 15:31
  • I'm trying to build a performant generic 2d game engine. It sounds like I should start looking into WebGL. Thx for the tip @AKX – Argatron Aug 09 '20 at 15:32
  • I also think I'm noticing small frame skips when running the effect, even though the fps never seems to drop. – Argatron Aug 09 '20 at 16:50
  • According to this source, Chrome can move painting off of the main thread by recording drawing actions to an SkPicture, which captures and later replays commands. It's possible that the `fillRect` calls are being captured but never run when the sub canvases are never drawn to the screen. https://www.chromium.org/developers/design-documents/gpu-accelerated-compositing-in-chrome – Argatron Aug 09 '20 at 17:26
  • 1
    [Here is the bug with the most information.](https://bugs.chromium.org/p/chromium/issues/detail?id=814219) So basically, it's what has been said in the first comment, and your "first though": They do cache bitmaps in the GPU, and every time you do redraw on it, it invalidates this cache and takes more time than what should happen. Note that before [this bug](https://bugs.chromium.org/p/chromium/issues/detail?id=806313) perfs were always fine. So IIUC, they sacrificed perfs for memory. – Kaiido Aug 10 '20 at 01:35

2 Answers2

0

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.

Argatron
  • 41
  • 4
-1

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.

frodo2975
  • 10,340
  • 3
  • 34
  • 41
  • I did find that source, regarding the canvases under 256px. I didn't notice any performance breakpoints when changing the canvas size, however. – Argatron Aug 10 '20 at 18:23
  • I might checkout pixijs. I really wanted to do this from scratch, but I've gone down the vector math/gpu rabbit hole before, and it's a deep one. – Argatron Aug 10 '20 at 18:27
  • 1
    Yup, sure is! If you're already familiar with webgl or opengl, it'll definitely be easier, but you're still looking at a lot of work. However, sometimes if it's a passion project and you just want to have fun, go for it. – frodo2975 Aug 10 '20 at 18:47