javascript – Canvas 2D context really slow to draw hundreds or thousands of images

I’m trying to create a multiplayer jigsaw puzzle game.

My first appoach was to use a <canvas> with a 2D rendering context, but the more I try, the more I think it’s impossible without switching to WebGL.

Here is an example of what I got:

In this case I’m rendering a 1900×1200 pixel image cut into 228 pieces, but I want the game to be able to render thousands of pieces with higher resolution.

Each piece is procedurally generated using bezier curves & straight lines with random variations, which gives me these kind of result:

Puzzle piece with outer tabs extended to the maximum
Puzzle piece with outer tabs extended to the minimum
Puzzle piece with inner tabs extended to the maximum
Puzzle piece with inner tabs extended to the minimum

I need each pieces to be rendered independently as the players will be able to drag & drop them around to rebuild the puzzle.

At first, I only had one <canvas> and I used the clip() method, followed by a drawImage() call for each pieces.

But quickly ran into performance issues when trying to render hundreds of pieces at 60fps (I’m running this on an old laptop, but I feel like this is not the problem).

Here is a shortened version of the code I’m using:

class PuzzleGenerator {
  public static generatePieces(
    puzzleWidth: number,
    puzzleHeight: number,
    horizontalPieceCount: number,
    verticalPieceCount: number,
  ): Array<Piece> {
    const pieceWidth = puzzleWidth / horizontalPieceCount;
    const pieceHeight = puzzleHeight / verticalPieceCount;
    const pieces: Array<Piece> = [];
    for (let x = 0; x < horizontalPieceCount; x++) {
      for (let y = 0; y < horizontalPieceCount; y++) {
        const pieceX = pieceWidth * x;
        const pieceY = pieceHeight * y;
        // For demonstration purpose I'm only drawing square pieces, but in reality it's much more complexe:
        // bezier curves, random variations, re-use of previous pieces to fit them together
        const piecePath = new Path2D();
        piecePath.moveTo(pieceX, pieceY);
        piecePath.lineTo(pieceX + pieceWidth, pieceY);
        piecePath.lineTo(pieceX + pieceWidth, pieceY + pieceHeight);
        piecePath.lineTo(pieceX, pieceY + pieceHeight);
        piecePath.closePath();
        pieces.push(new Piece(pieceX, pieceY, pieceWidth, pieceHeight, piecePath));
      }
    }
    return pieces;
  }
}
class Piece {
  constructor(
    public readonly x: number,
    public readonly y: number,
    public readonly width: number,
    public readonly height: number,
    public readonly path: Path2D,
  ) {}
}
class Puzzle {
  private readonly pieces: Array<Piece>;
  private readonly context: CanvasRenderingContext2D;

  constructor(
    private readonly canvas: HTMLCanvasElement,
    private readonly image: CanvasImageSource,
    private readonly puzzleWidth: number,
    private readonly puzzleHeight: number,
    private readonly horizontalPieceCount: number,
    private readonly verticalPieceCount: number,
  ) {
    this.canvas.width = puzzleWidth;
    this.canvas.height = puzzleHeight;
    this.context = canvas.getContext('2d') ?? ((): never => {throw new Error('Context identifier not supported');})();
    this.pieces = PuzzleGenerator.generatePieces(this.puzzleWidth, this.puzzleHeight, this.horizontalPieceCount, this.verticalPieceCount);
    this.loop();
  }

  private draw(): void {
    this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.pieces.forEach((piece) => {
      this.context.save();
      this.context.clip(piece.path);
      this.context.drawImage(
        this.image,
        piece.x, piece.y, piece.width, piece.height,
        piece.x, piece.y, piece.width, piece.height,
      );
      this.context.restore();
      this.context.save();
      this.context.strokeStyle="#fff";
      this.context.stroke(piece.path);
      this.context.restore();
    });
  }

  private loop(): void {
    requestAnimationFrame(() => {
      this.draw();
      this.loop();
    });
  }
}
const canvas = document.getElementById('puzzle') as HTMLCanvasElement;
const image = new Image();
image.src="https://stackoverflow.com/assets/puzzle.jpg";
image.onload = (): void => {
  new Puzzle(canvas, image, 1000, 1000, 10, 10);
};

To try to improve performance, I switched from one to multiple <canvas> (one for each piece + one for the puzzle)

Each piece is drawn by filling its path and drawing the image on top using globalCompositeOperation = 'source-atop';

But it resulted in even worse performance. Even though each piece was drawn only one time in its own canvas, they were the same size as the entire puzzle, acting as layers, and each layer then had to be drawn into the puzzle’s canvas each frame:

Reconstruction of the puzzle using layers

So I once again tried to optimize this, by reducing the canvas’s size of each piece to the minimum, so they act more like sprites instead of layers (the spacing around each piece is to accommodate for the random variations):

Reconstruction of the puzzle using sprites

Even though this optimization only removes transparent pixels, it has significantly increased the rendering performance.

At that point I’m able to draw hundreds of pieces at 60fps, but drawing thousands quickly drops me to 30fps or even lower.

To me, it looks like the 2D rendering context is having trouble to draw hundreds of images onto the same canvas, so whatever I do to improve the performance of drawing a single puzzle piece, it still won’t be enough once I scale the puzzle to add more & more pieces and increase the resolution.

Another problem I haven’t addressed yet is that I want the players to be able to zoom in & out on the puzzle, but when I tried to zoom in my canvas using scale()it also worsens the performance.

Also, I need to detect over which piece the player’s mouse currently is. I’m using isPointInPath but I suspect it could become another performance issue in the long term.

Is there some optimization I can try to improve the performance?

Am I doing something wrong that is killing the performance?

Have I reached the limit of what 2D rendering context can do?

Should I ditch 2D rendering context and switch to WebGL?

I was thinking about switching to PixiJS as it is a well know 2D rendering library, and it also has methods to draw shapes using bezier curves which may be useful to draw the puzzle’s pieces.

Leave a Comment