Canvas 2D 上下文绘制数百或数千张图像确实很慢

Typ*_*hon 7 javascript performance game-development html5-canvas typescript

我正在尝试创建一款多人拼图游戏。

我的第一个方法是使用<canvas>2D 渲染上下文,但我尝试得越多,我就越认为不切换到 WebGL 是不可能的。

这是我得到的一个例子:

全拼图

在本例中,我要渲染一个 1900x1200 像素的图像,并将其切割成 228 个部分,但我希望游戏能够以更高分辨率渲染数千个部分。

每一块都是使用具有随机变化的贝塞尔曲线和直线按程序生成的,这给了我这样的结果:

外部标签延伸到最大的拼图 外部标签延伸到最小的拼图 内部标签延伸到最大的拼图 内部标签延伸到最小的拼图

我需要独立渲染每个部分,因为玩家可以拖放它们来重建拼图。

使用剪辑()

起初,我只有一个<canvas>,我使用了该clip()方法,然后drawImage()对每个部分进行调用。

但当尝试以 60fps 渲染数百个片段时,很快就遇到了性能问题(我在一台旧笔记本电脑上运行它,但我觉得这不是问题)。

这是我正在使用的代码的缩短版本:

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;
  }
}
Run Code Online (Sandbox Code Playgroud)
class Piece {
  constructor(
    public readonly x: number,
    public readonly y: number,
    public readonly width: number,
    public readonly height: number,
    public readonly path: Path2D,
  ) {}
}
Run Code Online (Sandbox Code Playgroud)
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();
    });
  }
}
Run Code Online (Sandbox Code Playgroud)
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);
};
Run Code Online (Sandbox Code Playgroud)

使用全局组合

为了尝试提高性能,我从一个切换为多个<canvas>(每块一个+拼图一个)

通过填充其路径并使用在顶部绘制图像,将每个部分绘制在自己的屏幕外画布(MDN 优化画布)上globalCompositeOperation = 'source-atop';

但这导致了更糟糕的表现。尽管每一块只在自己的画布上绘制一次,但它们的大小与整个拼图相同,充当图层,并且每一层都必须在每一帧中绘制到拼图的画布中:

使用图层重建拼图

因此,我再次尝试优化这一点,通过将每块画布的大小减小到最小,使它们的行为更像精灵而不是图层(每块周围的间距是为了适应随机变化):

使用精灵重建谜题

尽管此优化仅删除了透明像素,但它却显着提高了渲染性能。

那时我能够以 60 fps 的速度绘制数百幅作品,但绘制数千幅作品很快就会使我的速度降至 30 fps 甚至更低。

对我来说,2D 渲染上下文似乎无法将数百个图像绘制到同一画布上,因此无论我采取什么措施来提高绘制单个拼图块的性能,一旦我缩放拼图,它仍然不够添加越来越多的碎片并提高分辨率。

其他性能问题

我尚未解决的另一个问题是我希望玩家能够放大和缩小拼图,但是当我尝试使用 放大画布时scale(),它也会使性能恶化。

另外,我需要检测玩家的鼠标当前位于哪一块上。我正在使用isPointInPath,但我怀疑从长远来看它可能会成为另一个性能问题。

您可能会问我的问题的答案

问:为什么不只绘制一次图像,然后在其上绘制线条?
A:是的,这样画拼图确实很快,但这不是我想要的。我的目标是制作一款益智游戏,其中的棋子一开始就被打乱,玩家可以移动它们来重建图像。

问:您是否尝试过在屏幕外画布上仅构建每个拼图一次,然后在可见画布上立即重建整个拼图?
答:是的,我有,它有帮助,但是当我向拼图中添加越来越多的碎片时,性能仍然会线性下降。

问:为什么要用旧笔记本电脑来调试这个?
A:嗯,硬件越好,帧率就越高,但我正在构建一个拼图游戏,我觉得它应该能够在低端硬件上运行。

问:您的笔记本电脑规格是什么?
答:这是一台 7 年的笔记本电脑,配备 i7 4712HQ 和 GT 750M(而且浏览器似乎使用的是集成 GPU,而不是专用 GPU)。

问:您确定性能问题来自drawImage()而不是来自贝塞尔曲线或其他计算吗?
答:是的,我确定,我简化到了最低限度,看起来绘制 50x50 像素图像一千次比绘制 100x100 像素图像 250 次要慢(即使最终全局绘制分辨率相同) )。

演示

这是一个 CodePen,您可以在其中调整从同一源图像绘制的块数。

它包括一个快速而肮脏的 FPS 计数器,可帮助您直观地看到性能下降情况。

drawImage() FPS 分析

这是没有 FPS 计数器的 TypeScript 版本,以提高可读性:

class Piece {
  constructor(
    public readonly x: number,
    public readonly y: number,
    public readonly width: number,
    public readonly height: number,
  ) {}
}
Run Code Online (Sandbox Code Playgroud)

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();
    });
  }
}
Run Code Online (Sandbox Code Playgroud)
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);
};
Run Code Online (Sandbox Code Playgroud)

我可以解决这个问题吗?

我可以尝试进行一些优化来提高性能吗?

我是否做错了什么导致性能下降?

我是否已达到 2D 渲染上下文的极限?

我应该放弃 2D 渲染上下文并切换到 WebGL 吗?

我正在考虑切换到 PixiJS,因为它是一个众所周知的 2D 渲染库,并且它还具有使用贝塞尔曲线绘制形状的方法,这可能有助于绘制拼图的各个部分。

JDB*_*JDB 3

如果没有性能分析,任何人都无法猜测到底是哪里出现了性能下降。你在黑暗中摸索,随机做出改变,希望能产生效果。

也就是说,有一些常见的技巧值得分享。您需要首先进行性能分析,但其中一些可能会在以后有所帮助。

在屏幕外绘制

绘制到可见画布是一项非常昂贵的操作。您希望绝对最小化立即可见的绘制操作的数量。相比之下,绘制到隐藏的画布非常快速且高效。将所有单独的绘图绘制到隐藏的画布上,然后一步将该隐藏的画布绘制到可见的画布上(如果可能)。

例如:

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

// ...

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

// ...

visibleCanvas.draw(pieceLayer, ...);
Run Code Online (Sandbox Code Playgroud)

留意热代码路径中的边际性能影响

尽管有人可能会告诉你,.forEach它并不像一个好的for循环那么快。.forEachfor没有的间接费用。不过,这就是分析的用武之地,因为很难判断它会对您的代码产生多大的影响。也许根本没有,也许是相当大的数量。差异很小,但如果您每秒多次处理非常大的集合,您将开始看到差异。(不要仅仅依赖建议:为自己运行测试,尽可能模仿您的用例。)

预渲染昂贵的图像

预渲染非常关键。您可能会想象这clip()是一个相对昂贵的操作。您尝试每 1/60 秒使用它一千次。

你不需要这样做。同样,您将需要进行一些性能分析,以确保这是值得的,但您可以预渲染每个片段并将其保存为图像,而不是要求画布不断重新计算所有剪报。

如果您不介意游戏是静态的,那么您现在可以预渲染图像,作为开发过程的一部分,并将它们保存到精灵表中。它看起来几乎与您已经发布的图像相同,其中各个部分在网格中彼此分开。然后,您可以绘制简单的矩形,而不用进行昂贵的裁剪。

如果您喜欢在每次加载时创建动态拼图(这听起来确实很酷),那么您仍然可以完成相同的任务。作为初始加载逻辑的一部分,用于clip()将每个拼图块绘制到单个隐藏的精灵表画布(以常规网格图案)。然后,在实际的渲染逻辑中,您可以利用预渲染的部分并大大简化处理器必须完成的工作。

尽量减少图像/画布的数量

将图像加载到内存中以便将它们绘制到画布上是众所周知的性能损失。这就是为什么精灵表在所有平台上如此常见......加载一张大图像并绘制子部分比绘制数百个必须加载然后从内存中清除的单独图像要快。

考虑使用getImageData/putImageData

这是针对更极端的情况,但这两个函数可以使您的画布免于进行大量处理图像的工作。本质上,您可以将图像绘制到画布上,然后用于getImageData提取将缓存在某处的原始位图数据。稍后,您可以将putImageData原始位图数据写入画布(可能是同一画布)。

然而,正确使用这些函数很复杂(尤其是所有CORS 问题),因此它不是一个简单的 1:1 替代。

分析您的代码

这些是一些一般性提示,可能对您有效,也可能无效。最重要的建议是我一开始的建议:分析你的代码。在知道在哪里切割之前,您必须先进行测量。盲目工作往往会让事情变得更糟,而不是更好。

个人例子

只是为了好玩,不久前我编写了一款不使用第三方库的塔防游戏。我只是使用了 Canvas 和标准的 2D 渲染上下文。游戏肯定还没有完成,但它能够在平铺背景上渲染数百个独立的动画精灵,没有任何明显的延迟。该游戏支持实时拖动视口,并且无论设置为什么缩放级别都可以顺利运行。欢迎您查看代码以获取我遗漏的任何提示或技巧:

cyborgx37 / 塔防-js