javascript - Canvas 2D-kontext verkligen långsam för att rita hundratals eller tusentals bilder
Jag försöker skapa ett flerspelarspel med pussel.
Mitt första tillvägagångssätt var att använda en <canvas> med ett 2D-renderingssammanhang, men ju mer jag försöker, desto mer tycker jag att det är omöjligt utan att byta till WebGL.
Här är ett exempel på vad jag fick:
I det här fallet renderar jag en 1900x1200 pixlar bild delad i 228 bitar, men jag vill att spelet ska kunna rendera tusentals bitar med högre upplösning.
Varje bit genereras procedurmässigt med hjälp av Bézier-kurvor och raka linjer med slumpmässiga variationer, vilket ger mig denna typ av resultat:
Jag behöver att varje bit ska renderas oberoende eftersom spelarna kommer att kunna dra och släppa dem för att återskapa pusslet.
Använder clip()
Först hade jag bara ett <canvas> och jag använde clip()-metoden, följt av ett drawImage()-anrop för varje bit.
Men stötte snabbt på prestandaproblem när jag försökte rendera hundratals delar i 60 fps (jag kör detta på en gammal laptop, men jag känner att det inte är problemet).
Här är en förkortad version av koden jag använder:
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);
};
Använda global sammansättning
För att försöka förbättra prestandan bytte jag från en till flera <canvas> (en för varje bit + en för pusslet)
Varje del ritas på sin egen offscreen-canvas (MDN Optimizing Canvas) genom att fylla dess väg och rita bilden ovanpå med globalCompositeOperation='source-atop';
Men det resulterade i ännu sämre prestanda. Även om varje bit bara ritades en gång i sin egen canvas, var de samma storlek som hela pusslet, agerade som lager, och varje lager behövde sedan ritas in i pusslets canvas varje bildruta:
Så jag försökte igen att optimera detta, genom att minska varje dels canvastyp till minimum, så att de fungerar mer som sprites istället för lager (mellanrummet runt varje del är för att göra plats för de slumpmässiga variationerna):
Även om denna optimering endast tar bort genomskinliga pixlar, har den avsevärt ökat renderingsprestandan.
Vid den tidpunkten kan jag rita hundratals objekt vid 60 fps, men att rita tusentals sänker snabbt farten till 30 fps eller ännu lägre.
För mig ser det ut som att 2D-renderingskontexten har svårt att rita hundratals bilder på samma canvas, så oavsett vad jag gör för att förbättra prestandan när jag ritar en enskild pusselbit, kommer det ändå inte att räcka när jag skalar pusslet för att lägga till fler och fler bitar och öka upplösningen.
Andra prestandaproblem
Ett annat problem jag inte har tagit upp än är att jag vill att spelarna ska kunna zooma in och ut på pusslet, men när jag försökte zooma in på min canvas med scale() försämrades också prestandan.
Jag behöver också upptäcka över vilken bricka spelarens mus för närvarande är. Jag använder isPointInPath men jag misstänker att det på lång sikt kan bli ett annat prestandaproblem.
Svar på frågor du kan ställa till mig
F: Varför ritar du inte bilden bara en gång och sedan ritar linjerna ovanpå den?
S: Ja, det är verkligen snabbt att rita pusslet på det sättet, men det är inte vad jag letar efter. Mitt mål är att ha ett pusselspel där bitarna är blandade i början och spelaren kan flytta dem för att återskapa bilden.
F: Har du försökt bygga varje bit bara en gång på en offscreen-canvas och sedan bygga hela pusslet på en gång på den synliga canvasen?
S: Ja, det har jag, det hjälper, men det finns fortfarande en linjär prestandanedgång när jag lägger till fler och fler bitar i pusslet.
F: Varför använder du en gammal bärbar dator för att felsöka detta?
S: Jo, ju bättre hårdvara, desto bättre blir bildfrekvensen, men jag bygger ett pusselspel och jag känner att det bör kunna köras på lågpresterande hårdvara.
Q: Vad är dina bärbara dators specifikationer?
A: Det är en 7 år gammal bärbar dator med en i7 4712HQ och en GT 750M (det verkar också som att webbläsaren använder den integrerade GPU:n istället för den dedikerade GPU:n).
F: Är du säker på att prestandaproblemen kommer från drawImage() och inte från Bezier-kurvorna eller andra beräkningar?
S: Ja, jag är säker, jag förenklade till det absolut minimala och det verkar som att rita en 50x50 pixlar bild tusen gånger är långsammare än att rita en 100x100 pixlar bild 250 gånger (även om den globala ritupplösningen är densamma i slutändan).
Demo
Här är en CodePen där du kan leka med antalet bitar att rita från samma källbild.
Det inkluderar en snabb och enkel FPS-räknare för att hjälpa dig visualisera prestandafall.
drawImage() FPS-profilering
Och här är TypeScript-versionen utan FPS-räknare för bättre läsbarhet:
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);
};
Kan jag lösa det här?
Finns det någon optimering jag kan försöka för att förbättra prestandan?
Gör jag något fel som försämrar prestandan?
Har jag nått gränsen för vad 2D-renderingskontexten kan göra?
Ska jag överge 2D-renderingskontexten och byta till WebGL?
Jag funderade på att byta till PixiJS eftersom det är ett välkänt 2D-renderingsbibliotek, och det har också metoder för att rita former med hjälp av Bezier-kurvor vilket kan vara användbart för att rita pusslets bitar.
Svar #1
Utan prestandaprofilering är det vem som helst gissning var exakt nedgången sker. Du famlar i mörkret och gör slumpmässiga ändringar i hopp om att det kan ha en effekt.
Det sagt, finns det ett par vanliga tips som är värda att dela. Du kommer att behöva göra prestandaprofileringen först, men några av dessa kan vara hjälpsamma senare.
Rita utanför skärmen
Att rita på en synlig duk är en mycket kostsam operation. Du vill absolut minimera antalet omedelbart synliga ritoperationer. Jämförelsevis är det mycket snabbt och effektivt att rita på en dold duk. Gör alla dina individuella bitritningar på en dold duk, och rita sedan den dolda duken på den synliga duken i ett enda steg (om möjligt).
T.ex.:
const pieceLayer = document.createElement("canvas").getContext("2d");
// ...
for (const piece of this.pieces) {
pieceLayer.draw(...);
}
// ...
visibleCanvas.draw(pieceLayer, ...);
Se upp för marginella prestandaförluster i varma kodvägar
Trots vad någon än kan säga till dig, är .forEach inte lika snabb som en gammal hederlig for-slinga. .forEach har overhead som for inte har. Det är här profilering kommer in, eftersom det är svårt att säga hur stor skillnad det skulle göra för din kod. Kanske ingen alls, kanske en märkbar mängd. Skillnaderna är små, men om du bearbetar mycket stora samlingar många gånger per sekund kommer du att börja märka skillnaden. (Lita inte enbart på råd: kör tester själv som efterliknar ditt användningsfall så nära som möjligt.)
För-rendera dyra bilder
Förrendering är verkligen avgörande. Du kan förmodligen föreställa dig att clip() är en relativt dyr operation. Du försöker använda den tusen gånger varje 1/60-del av en sekund.
Du behöver inte göra det. Återigen, du kommer att behöva göra viss prestandaprofilerning för att säkerställa att detta ens är värt ansträngningen, men du kan för-rendera varje del och spara det som en bild istället för att kräva att Canvas ständigt beräknar om alla dina utskärningar.
Om du inte har något emot att ditt spel är statiskt, kan du för-rendera dina bilder nu, som en del av utvecklingsprocessen, och spara dem i ett sprite-ark. Det skulle se nästan identiskt ut med bilden du redan har lagt upp, där bitarna är separerade från varandra i ett rutnät. Istället för dyr beskärning kan du sedan bara rita enkla rektanglar.
Om du gillar att skapa dynamiska pusselbitar vid varje laddning (vilket faktiskt låter ganska häftigt) kan du fortfarande uppnå samma resultat. Som en del av din initiala laddningslogik, använd clip() för att rita varje pusselbit på en enkel dold sprite sheet-canvas (i ett ordnat rutnät). Sedan, i din faktiska renderingslogik, kan du dra nytta av de förrenderade bitarna och kraftigt förenkla arbetet som processorn måste göra.
Minimera antalet bilder/dukar
Att ladda bilder i minnet så att de kan ritas på en canvas är en välkänd prestandapåverkan. Det är därför spritesheets är så vanliga på alla plattformar... att ladda en stor bild och rita delsektioner är snabbare än att rita hundratals individuella bilder som måste laddas och sedan tas bort från minnet.
Överväg att använda getImageData/putImageData
Detta är för mer extrema fall, men dessa två funktioner kan rädda din canvas från att göra mycket arbete med att bearbeta bilder. I princip kan du rita din bild på en canvas, och sedan använda getImageData för att extrahera den råa bitmap-datan som du skulle cacha någonstans. Senare kan du använda putImageData för att skriva den råa bitmap-datan till en canvas (kanske samma canvas).
Dock är dessa funktioner komplicerade att använda korrekt (inte minst på grund av CORS-problem), så det är inte en enkel 1:1-ersättning.
Profilera din kod
Detta är några allmänna tips som kanske fungerar för dig eller inte. Det viktigaste rådet, dock, är det jag började med: profilera din kod. Du måste mäta innan du vet var du ska skära. Att arbeta i blindo tenderar att göra saker värre, inte bättre.
Ett personligt exempel
Bara för skojs skull, för ett tag sedan skrev jag ett tower-defense-spel utan att använda några tredjepartsbibliotek. Jag använde helt enkelt en Canvas och en standard 2D Rendering Context. Spelet är definitivt inte färdigt, men det kan återge hundratals individuellt animerade sprites på en flistäckad bakgrund utan någon märkbar fördröjning. Spelet stöder att du kan dra runt vyfältet i realtid och fungerar smidigt oavsett vilken zoomnivå det är inställt på. Du är välkommen att titta på koden för eventuella tips eller tricks som jag har utelämnat:
cyborgx37/tower-defense-js






