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 1900x1200 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:
I need each pieces to be rendered independently as the players will be able to drag & drop them around to rebuild the puzzle.
Using clip()
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 < verticalPieceCount; 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 = '/assets/puzzle.jpg';
image.onload = (): void => {
new Puzzle(canvas, image, 1000, 1000, 10, 10);
};
Using global composition
To try to improve performance, I switched from one to multiple <canvas>
(one for each piece + one for the puzzle)
Each piece is drawn on its own offscreen canvas (MDN Optimizing Canvas) 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:
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):
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.
Other performance issues
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 worsen 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.
Answers to questions you might ask me
Q: Why don't you draw the image only once and then draw the lines on top of it?
A: Yes, it is really fast to draw the puzzle that way, but this is not what I am looking for. My goal is to have a puzzle game where the pieces are scrambled at the beginning and the player can move them to rebuild the image.
Q: Have you tried building each piece only once on an offscreen canvas and then rebuild the entire puzzle at once on the visible canvas?
A: Yes I have, it helps, but there still is a linear performance decrease when I add more and more pieces to the puzzle.
Q: Why are you using an old laptop to debug this?
A: Well, the better the hardware, the better the framerate will be, but I'm building a jigsaw puzzle game, I feel like it should be able to run on low end hardware.
Q: What are your laptop specs?
A: It is a 7 year old laptop with an i7 4712HQ and a GT 750M (also it appears that the browser is using the integrated GPU instead of the dedicated GPU).
Q: Are you sure the performance issues come from the drawImage()
and not from the bezier curves or other computations?
A: Yes, I'm sure, I simplified to the bare minimum and it appears that drawing a 50x50 pixel image a thousand times is slower than drawing a 100x100 pixel image 250 times (even though the global drawing resolution is the same at the end).
Demo
Here is a CodePen where you can play with the number of pieces to draw from the same source image.
It include a quick and dirty FPS counter to help you visualize performance drop.
And here is the TypeScript version without the FPS counter for better readability:
class Piece {
constructor(
public readonly x: number,
public readonly y: number,
public readonly width: number,
public readonly height: number,
) {}
}
export class Puzzle {
private readonly pieces: Array<Piece>;
private readonly context: CanvasRenderingContext2D;
private mousePosition?: {x: number; y: number};
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 = this.canvas.getContext('2d') ?? ((): never => {
throw new Error('Context identifier not supported');
})();
this.pieces = this.generatePieces();
this.trackMousePosition();
this.loop();
}
private generatePieces(): Array<Piece> {
const pieceWidth = this.puzzleWidth / this.horizontalPieceCount;
const pieceHeight = this.puzzleHeight / this.verticalPieceCount;
const pieces = [];
for (let x = 0; x < this.horizontalPieceCount; x++) {
for (let y = 0; y < this.verticalPieceCount; y++) {
const pieceX = pieceWidth * x;
const pieceY = pieceHeight * y;
pieces.push(new Piece(pieceX, pieceY, pieceWidth, pieceHeight));
}
}
return pieces;
}
private trackMousePosition(): void {
this.canvas.addEventListener('mousemove', (event) => {
this.mousePosition = {
x: event.pageX - this.canvas.offsetLeft,
y: event.pageY - this.canvas.offsetTop,
};
}, {passive: true});
}
private draw(): void {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
for (const piece of this.pieces) {
this.context.drawImage(
this.image,
piece.x, piece.y, piece.width, piece.height,
piece.x, piece.y, piece.width, piece.height,
);
}
if (this.mousePosition) {
this.context.beginPath();
this.context.arc(this.mousePosition.x, this.mousePosition.y, 10, 0, 2 * Math.PI);
this.context.fillStyle = '#f00';
this.context.fill();
}
}
private loop(): void {
requestAnimationFrame(() => {
this.draw();
this.loop();
});
}
}
const canvas = document.getElementById('puzzle');
const image = new Image();
const imageWidth = 2000;
const imageHeight = 1000;
const horizontalPieceCount = 10;
const verticalPieceCount = 10;
image.src = `https://picsum.photos/${imageWidth}/${imageHeight}`;
image.onload = (): void => {
new Puzzle(canvas, image, imageWidth, imageHeight, horizontalPieceCount, verticalPieceCount);
};
Can I solve this?
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.
drawImage
. I had some success withputImageData
, but it's got problems too. – MyologygetImageData
/putImageData
is the trick. Just watch out for CORS issues with getImageData... it doesn't like "tainted" canvases. – Myology