Búsqueda de sitios web

javascript - El contexto 2D del Canvas es realmente lento para dibujar cientos o miles de imágenes


Estoy intentando crear un juego de rompecabezas multijugador.

Mi primer enfoque fue usar un <canvas> con un contexto de renderizado 2D, pero cuanto más intento, más pienso que es imposible sin cambiar a WebGL.

Aquí hay un ejemplo de lo que obtuve:

En este caso estoy renderizando una imagen de 1900x1200 píxeles dividida en 228 piezas, pero quiero que el juego pueda renderizar miles de piezas con mayor resolución.

Cada pieza se genera de manera procedural utilizando curvas de Bézier y líneas rectas con variaciones aleatorias, lo que me da este tipo de resultado:

Necesito que cada pieza se represente de manera independiente, ya que los jugadores podrán arrastrarlas y soltarlas para reconstruir el rompecabezas.

Usando clip()

Al principio, solo tenía un <canvas> y usaba el método clip(), seguido de una llamada a drawImage() para cada pieza.

Pero rápidamente me encontré con problemas de rendimiento al intentar renderizar cientos de piezas a 60 fps (estoy ejecutando esto en una computadora portátil vieja, pero siento que ese no es el problema).

Aquí está una versión reducida del código que estoy usando:

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);
};

Usando composición global

Para intentar mejorar el rendimiento, cambié de uno a varios <canvas> (uno para cada pieza + uno para el rompecabezas)

Cada pieza se dibuja en su propio lienzo fuera de pantalla (MDN Optimizing Canvas) llenando su ruta y dibujando la imagen encima usando globalCompositeOperation='source-atop';

Pero resultó en un rendimiento aún peor. Aunque cada pieza se dibujara solo una vez en su propio lienzo, tenían el mismo tamaño que todo el rompecabezas, actuando como capas, y luego cada capa debía dibujarse en el lienzo del rompecabezas en cada fotograma:

Así que una vez más intenté optimizar esto, reduciendo el tamaño del lienzo de cada pieza al mínimo, para que actúen más como sprites en lugar de capas (el espacio alrededor de cada pieza es para acomodar las variaciones aleatorias):

Aunque esta optimización solo elimina píxeles transparentes, ha aumentado significativamente el rendimiento de renderizado.

En ese punto puedo dibujar cientos de piezas a 60 fps, pero dibujar miles rápidamente me baja a 30 fps o incluso menos.

Para mí, parece que el contexto de renderizado 2D tiene problemas para dibujar cientos de imágenes en el mismo lienzo, así que haga lo que haga para mejorar el rendimiento al dibujar una sola pieza del rompecabezas, aún no será suficiente una vez que escale el rompecabezas para agregar más y más piezas y aumentar la resolución.

Otros problemas de rendimiento

Otro problema que aún no he abordado es que quiero que los jugadores puedan acercar y alejar el rompecabezas, pero cuando intenté acercar mi lienzo usando scale(), también empeoró el rendimiento.

Además, necesito detectar sobre qué pieza está actualmente el ratón del jugador. Estoy usando isPointInPath, pero sospecho que podría convertirse en otro problema de rendimiento a largo plazo.

Respuestas a preguntas que podrías hacerme

P: ¿Por qué no dibujas la imagen solo una vez y luego dibujas las líneas encima?
R: Sí, realmente es rápido dibujar el rompecabezas de esa manera, pero esto no es lo que estoy buscando. Mi objetivo es tener un juego de rompecabezas donde las piezas estén mezcladas al principio y el jugador pueda moverlas para reconstruir la imagen.

P: ¿Has intentado construir cada pieza solo una vez en un lienzo fuera de pantalla y luego reconstruir todo el rompecabezas de una vez en el lienzo visible?
R: Sí, lo he hecho, ayuda, pero todavía hay una disminución de rendimiento lineal cuando agrego más y más piezas al rompecabezas.

P: ¿Por qué estás usando una laptop vieja para depurar esto?
R: Bueno, mientras mejor sea el hardware, mejor será la tasa de fotogramas, pero estoy construyendo un juego de rompecabezas, siento que debería poder ejecutarse en hardware de gama baja.

P: ¿Cuáles son las especificaciones de tu portátil?
R: Es un portátil de 7 años con un i7 4712HQ y una GT 750M (además, parece que el navegador está usando la GPU integrada en lugar de la GPU dedicada).

P: ¿Estás seguro de que los problemas de rendimiento provienen de drawImage() y no de las curvas bézier u otros cálculos?
R: Sí, estoy seguro, lo simplifiqué al mínimo y parece que dibujar una imagen de 50x50 píxeles mil veces es más lento que dibujar una imagen de 100x100 píxeles 250 veces (aunque la resolución de dibujo global sea la misma al final).

Demostración

Aquí hay un CodePen donde puedes jugar con el número de piezas para dibujar de la misma imagen fuente.

Incluye un contador de FPS rápido y sencillo para ayudarte a visualizar la caída del rendimiento.

Perfilado de FPS de drawImage()

Y aquí está la versión de TypeScript sin el contador de FPS para una mejor legibilidad:

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);
};

¿Puedo resolver esto?

¿Hay alguna optimización que pueda intentar para mejorar el rendimiento?

¿Estoy haciendo algo mal que está matando el rendimiento?

¿He alcanzado el límite de lo que el contexto de renderizado 2D puede hacer?

¿Debería abandonar el contexto de renderizado 2D y cambiar a WebGL?

Estaba pensando en cambiar a PixiJS ya que es una biblioteca de renderizado 2D bien conocida, y también tiene métodos para dibujar formas usando curvas de Bézier, lo cual puede ser útil para dibujar las piezas del rompecabezas.

Respuesta #1

Sin un perfil de rendimiento, es una suposición de cualquiera dónde exactamente está ocurriendo la desaceleración. Estás tanteando en la oscuridad y haciendo cambios al azar con la esperanza de que puedan tener algún efecto.

Dicho esto, hay un par de consejos comunes que vale la pena compartir. Primero necesitarás hacer el perfilado de rendimiento, pero algunos de estos podrían ser útiles más adelante.

Dibujar fuera de pantalla

Dibujar en un lienzo visible es una operación muy costosa. Quieres minimizar absolutamente el número de operaciones de dibujo inmediatamente visibles. En comparación, dibujar en un lienzo oculto es muy rápido y eficiente. Haz todos tus dibujos de piezas individuales en un lienzo oculto, luego dibuja ese lienzo oculto en el lienzo visible en un solo paso (si es posible).

Por ejemplo:

const pieceLayer = document.createElement("canvas").getContext("2d");

// ...

for (const piece of this.pieces) {
  pieceLayer.draw(...);
}

// ...

visibleCanvas.draw(pieceLayer, ...);

Cuidado con los impactos de rendimiento marginal en las rutas de código caliente

A pesar de lo que cualquiera pueda decirte, .forEach no es tan rápido como un buen y viejo bucle for. .forEach tiene sobrecargas que for no tiene. Aquí es donde entra en juego el perfilado, porque es difícil saber cuánto de diferencia haría para tu código. Quizás ninguna, quizás una cantidad notable. Las diferencias son mínimas, pero si estás procesando colecciones muy grandes muchas veces por segundo, empezarás a notar una diferencia. (No confíes únicamente en consejos: realiza pruebas por ti mismo que imiten tu caso de uso lo más fielmente posible.)

Pre-renderizar imágenes costosas

La pre-renderización es realmente crítica. Probablemente puedas imaginar que clip() es una operación relativamente costosa. Estás tratando de usarla mil veces cada 1/60 de segundo.

No necesitas hacer eso. Nuevamente, necesitarás hacer algunas pruebas de rendimiento para asegurarte de que esto siquiera vale la pena, pero puedes pre-renderizar cada pieza y guardarla como una imagen en lugar de requerir que el Canvas recalcule constantemente todos tus recortes.

Si no te importa que tu juego sea estático, entonces puedes pre-renderizar tus imágenes ahora, como parte del proceso de desarrollo, y guardarlas en una hoja de sprites. Se vería casi idéntico a la imagen que ya has publicado, donde las piezas están separadas unas de otras en una cuadrícula. Luego, en lugar de realizar recortes costosos, simplemente puedes dibujar rectángulos simples.

Si te gusta crear piezas de rompecabezas dinámicas en cada carga (lo cual suena bastante genial), entonces aún podrías lograr el mismo resultado. Como parte de tu lógica de carga inicial, usa clip() para dibujar cada pieza del rompecabezas en un lienzo de sprite oculto único (en un patrón de cuadrícula regular). Luego, en tu lógica de renderizado real, puedes aprovechar las piezas pre-renderizadas y simplificar enormemente el trabajo que el procesador tiene que hacer.

Minimizar el número de imágenes/lienzos

Cargar imágenes en la memoria para que puedan ser dibujadas en un lienzo es un golpe de rendimiento bien conocido. Por eso las hojas de sprites son tan comunes en todas las plataformas... cargar una imagen grande y dibujar subsecciones es más rápido que dibujar cientos de imágenes individuales que deben ser cargadas y luego eliminadas de la memoria.

Considera usar getImageData/putImageData

Esto es para casos más extremos, pero estas dos funciones pueden salvar tu lienzo de hacer mucho trabajo procesando imágenes. Esencialmente, puedes dibujar tu imagen en un lienzo, luego usar getImageData para extraer los datos de mapa de bits en bruto, que guardarías en algún lugar. Más tarde, puedes usar putImageData para escribir los datos de mapa de bits en bruto en un lienzo (quizás el mismo lienzo).

Sin embargo, estas funciones son complicadas de usar correctamente (y no menos importantes, los problemas de CORS), por lo que no es un reemplazo ligero 1:1.

Perfila tu código

Estos son algunos consejos generales que pueden o no ser efectivos para ti. Sin embargo, el consejo más importante es el que mencioné al principio: perfila tu código. Tienes que medir antes de saber dónde recortar. Trabajar a ciegas tiende a empeorar las cosas, no a mejorarlas.

Un ejemplo personal

Solo por diversión, hace un tiempo escribí un juego de defensa de torres sin usar bibliotecas de terceros. Simplemente usé un Canvas y un Contexto de Renderizado 2D estándar. El juego definitivamente no está terminado, pero es capaz de renderizar cientos de sprites animados individualmente sobre un fondo con mosaicos sin ningún retraso notable. El juego permite arrastrar la vista en tiempo real y funciona de manera fluida sin importar el nivel de zoom al que esté configurado. Eres bienvenido a revisar el código en busca de cualquier consejo o truco que haya omitido:

cyborgx37/defensa-de-torres-js

Artículos relacionados