技術解説

Canvas APIを使った描画の最適化

Canvas APIを使った描画を最適化する方法を、実際のコード例とともに詳しく解説します。

2500文字
Canvas APIパフォーマンス最適化Web開発アニメーション

Canvas APIを使った描画の最適化

Canvas APIを使った描画を最適化する方法を、実際のコード例とともに詳しく解説します。パフォーマンス向上のためのテクニックから、メモリ管理まで、実践的な最適化手法を紹介します。

Canvas APIの描画最適化の重要性

Canvas APIは、2Dグラフィックスを描画するための強力なAPIですが、適切に最適化しないとパフォーマンスの問題が発生します。特に、物理シミュレーションやアニメーションなど、リアルタイムで描画を更新する場合、最適化が重要になります。

最適化が必要な理由

  1. 60FPSの維持: 滑らかなアニメーションには60FPS(1秒間に60フレーム)の描画が必要
  2. メモリ使用量の削減: 不適切な描画はメモリリークの原因になる
  3. バッテリー消費の削減: 最適化により、モバイル端末のバッテリー消費を削減
  4. ユーザー体験の向上: 最適化により、快適な操作感を提供

基本的な最適化テクニック

1. 再描画範囲の最小化

画面全体を再描画するのではなく、変更された部分だけを再描画することで、パフォーマンスを向上させます。

/**
 * 再描画範囲を最小化する描画関数
 */
export class OptimizedCanvasRenderer {
  private ctx: CanvasRenderingContext2D;
  private width: number;
  private height: number;
  private dirtyRegions: Array<{ x: number; y: number; width: number; height: number }> = [];

  constructor(canvas: HTMLCanvasElement) {
    const ctx = canvas.getContext('2d');
    if (!ctx) {
      throw new Error('Canvas context not available');
    }
    this.ctx = ctx;
    this.width = canvas.width;
    this.height = canvas.height;
  }

  /**
   * 再描画が必要な領域をマーク
   */
  markDirty(x: number, y: number, width: number, height: number): void {
    this.dirtyRegions.push({ x, y, width, height });
  }

  /**
   * マークされた領域だけをクリア
   */
  clearDirtyRegions(): void {
    for (const region of this.dirtyRegions) {
      this.ctx.clearRect(region.x, region.y, region.width, region.height);
    }
    this.dirtyRegions = [];
  }

  /**
   * 全体をクリア(必要な場合のみ)
   */
  clearAll(): void {
    this.ctx.clearRect(0, 0, this.width, this.height);
  }
}

2. オフスクリーンキャンバスの活用

複雑な描画をオフスクリーンキャンバスで行い、メインキャンバスにコピーすることで、パフォーマンスを向上させます。

/**
 * オフスクリーンキャンバスを使った描画
 */
export class OffscreenCanvasRenderer {
  private mainCanvas: HTMLCanvasElement;
  private mainCtx: CanvasRenderingContext2D;
  private offscreenCanvas: HTMLCanvasElement;
  private offscreenCtx: CanvasRenderingContext2D;

  constructor(canvas: HTMLCanvasElement) {
    this.mainCanvas = canvas;
    const ctx = canvas.getContext('2d');
    if (!ctx) {
      throw new Error('Canvas context not available');
    }
    this.mainCtx = ctx;

    // オフスクリーンキャンバスの作成
    this.offscreenCanvas = document.createElement('canvas');
    this.offscreenCanvas.width = canvas.width;
    this.offscreenCanvas.height = canvas.height;
    const offscreenCtx = this.offscreenCanvas.getContext('2d');
    if (!offscreenCtx) {
      throw new Error('Offscreen canvas context not available');
    }
    this.offscreenCtx = offscreenCtx;
  }

  /**
   * オフスクリーンキャンバスに描画
   */
  drawToOffscreen(drawFunction: (ctx: CanvasRenderingContext2D) => void): void {
    this.offscreenCtx.clearRect(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height);
    drawFunction(this.offscreenCtx);
  }

  /**
   * オフスクリーンキャンバスをメインキャンバスにコピー
   */
  copyToMain(): void {
    this.mainCtx.clearRect(0, 0, this.mainCanvas.width, this.mainCanvas.height);
    this.mainCtx.drawImage(this.offscreenCanvas, 0, 0);
  }

  /**
   * オフスクリーンキャンバスのコンテキストを取得
   */
  getOffscreenContext(): CanvasRenderingContext2D {
    return this.offscreenCtx;
  }
}

3. 描画コンテキストの再利用

描画コンテキストの設定(色、線の太さなど)を頻繁に変更すると、パフォーマンスが低下します。同じ設定の描画をまとめて行うことで、最適化できます。

/**
 * 描画コンテキストの設定を最適化
 */
export class OptimizedContext {
  private ctx: CanvasRenderingContext2D;
  private currentFillStyle: string | null = null;
  private currentStrokeStyle: string | null = null;
  private currentLineWidth: number | null = null;

  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;
  }

  /**
   * フィルスタイルを設定(変更時のみ)
   */
  setFillStyle(style: string): void {
    if (this.currentFillStyle !== style) {
      this.ctx.fillStyle = style;
      this.currentFillStyle = style;
    }
  }

  /**
   * ストロークスタイルを設定(変更時のみ)
   */
  setStrokeStyle(style: string): void {
    if (this.currentStrokeStyle !== style) {
      this.ctx.strokeStyle = style;
      this.currentStrokeStyle = style;
    }
  }

  /**
   * 線の太さを設定(変更時のみ)
   */
  setLineWidth(width: number): void {
    if (this.currentLineWidth !== width) {
      this.ctx.lineWidth = width;
      this.currentLineWidth = width;
    }
  }

  /**
   * 描画を開始(設定をリセット)
   */
  beginPath(): void {
    this.ctx.beginPath();
  }

  /**
   * 描画を終了
   */
  fill(): void {
    this.ctx.fill();
  }

  /**
   * ストロークを描画
   */
  stroke(): void {
    this.ctx.stroke();
  }
}

高度な最適化テクニック

1. レイヤー分離

背景と前景を別々のキャンバスに描画することで、背景の再描画を避けられます。

/**
 * レイヤー分離による最適化
 */
export class LayeredCanvasRenderer {
  private backgroundCanvas: HTMLCanvasElement;
  private backgroundCtx: CanvasRenderingContext2D;
  private foregroundCanvas: HTMLCanvasElement;
  private foregroundCtx: CanvasRenderingContext2D;
  private mainCanvas: HTMLCanvasElement;
  private mainCtx: CanvasRenderingContext2D;

  constructor(mainCanvas: HTMLCanvasElement) {
    this.mainCanvas = mainCanvas;
    const ctx = mainCanvas.getContext('2d');
    if (!ctx) {
      throw new Error('Canvas context not available');
    }
    this.mainCtx = ctx;

    // 背景キャンバスの作成
    this.backgroundCanvas = document.createElement('canvas');
    this.backgroundCanvas.width = mainCanvas.width;
    this.backgroundCanvas.height = mainCanvas.height;
    const bgCtx = this.backgroundCanvas.getContext('2d');
    if (!bgCtx) {
      throw new Error('Background canvas context not available');
    }
    this.backgroundCtx = bgCtx;

    // 前景キャンバスの作成
    this.foregroundCanvas = document.createElement('canvas');
    this.foregroundCanvas.width = mainCanvas.width;
    this.foregroundCanvas.height = mainCanvas.height;
    const fgCtx = this.foregroundCanvas.getContext('2d');
    if (!fgCtx) {
      throw new Error('Foreground canvas context not available');
    }
    this.foregroundCtx = fgCtx;
  }

  /**
   * 背景を描画(一度だけ)
   */
  drawBackground(drawFunction: (ctx: CanvasRenderingContext2D) => void): void {
    drawFunction(this.backgroundCtx);
  }

  /**
   * 前景を描画(毎フレーム)
   */
  drawForeground(drawFunction: (ctx: CanvasRenderingContext2D) => void): void {
    this.foregroundCtx.clearRect(0, 0, this.foregroundCanvas.width, this.foregroundCanvas.height);
    drawFunction(this.foregroundCtx);
  }

  /**
   * レイヤーを合成してメインキャンバスに描画
   */
  composite(): void {
    this.mainCtx.clearRect(0, 0, this.mainCanvas.width, this.mainCanvas.height);
    this.mainCtx.drawImage(this.backgroundCanvas, 0, 0);
    this.mainCtx.drawImage(this.foregroundCanvas, 0, 0);
  }
}

2. バッチ描画

複数の描画操作をまとめて実行することで、パフォーマンスを向上させます。

/**
 * バッチ描画による最適化
 */
export class BatchRenderer {
  private ctx: CanvasRenderingContext2D;
  private drawOperations: Array<() => void> = [];

  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;
  }

  /**
   * 描画操作を追加
   */
  addDrawOperation(operation: () => void): void {
    this.drawOperations.push(operation);
  }

  /**
   * すべての描画操作を実行
   */
  execute(): void {
    // 同じ設定の描画をまとめて実行
    this.ctx.save();
    
    for (const operation of this.drawOperations) {
      operation();
    }
    
    this.ctx.restore();
    this.drawOperations = [];
  }
}

3. 視覚領域外の描画をスキップ

画面に表示されない領域の描画をスキップすることで、パフォーマンスを向上させます。

/**
 * 視覚領域外の描画をスキップ
 */
export class CullingRenderer {
  private ctx: CanvasRenderingContext2D;
  private viewport: { x: number; y: number; width: number; height: number };

  constructor(
    ctx: CanvasRenderingContext2D,
    viewport: { x: number; y: number; width: number; height: number }
  ) {
    this.ctx = ctx;
    this.viewport = viewport;
  }

  /**
   * オブジェクトが視覚領域内にあるかチェック
   */
  isVisible(x: number, y: number, width: number, height: number): boolean {
    return (
      x + width >= this.viewport.x &&
      x <= this.viewport.x + this.viewport.width &&
      y + height >= this.viewport.y &&
      y <= this.viewport.y + this.viewport.height
    );
  }

  /**
   * 視覚領域内の場合のみ描画
   */
  drawIfVisible(
    x: number,
    y: number,
    width: number,
    height: number,
    drawFunction: () => void
  ): void {
    if (this.isVisible(x, y, width, height)) {
      drawFunction();
    }
  }
}

メモリ管理の最適化

1. 画像のキャッシュ

頻繁に使用する画像をキャッシュすることで、メモリ使用量を削減し、パフォーマンスを向上させます。

/**
 * 画像キャッシュ
 */
export class ImageCache {
  private cache: Map<string, HTMLImageElement> = new Map();
  private maxSize: number;

  constructor(maxSize: number = 100) {
    this.maxSize = maxSize;
  }

  /**
   * 画像を取得(キャッシュから、または読み込み)
   */
  async getImage(src: string): Promise<HTMLImageElement> {
    if (this.cache.has(src)) {
      return this.cache.get(src)!;
    }

    const img = new Image();
    img.src = src;

    await new Promise((resolve, reject) => {
      img.onload = resolve;
      img.onerror = reject;
    });

    // キャッシュサイズを制限
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }

    this.cache.set(src, img);
    return img;
  }

  /**
   * キャッシュをクリア
   */
  clear(): void {
    this.cache.clear();
  }
}

2. パスの再利用

同じパスを複数回使用する場合、Path2Dオブジェクトを再利用します。

/**
 * パスの再利用
 */
export class PathCache {
  private cache: Map<string, Path2D> = new Map();

  /**
   * パスを取得(キャッシュから、または作成)
   */
  getPath(key: string, createFunction: () => Path2D): Path2D {
    if (this.cache.has(key)) {
      return this.cache.get(key)!;
    }

    const path = createFunction();
    this.cache.set(key, path);
    return path;
  }

  /**
   * キャッシュをクリア
   */
  clear(): void {
    this.cache.clear();
  }
}

パフォーマンス測定

FPSの監視

FPSを監視して、パフォーマンスの問題を早期に発見します。

/**
 * FPS監視クラス
 */
export class FPSMonitor {
  private frames: number[] = [];
  private lastTime: number = performance.now();
  private frameCount: number = 0;
  private maxSamples: number = 60;

  /**
   * フレームを記録
   */
  recordFrame(): void {
    const now = performance.now();
    const delta = now - this.lastTime;
    this.lastTime = now;

    this.frames.push(1000 / delta);
    if (this.frames.length > this.maxSamples) {
      this.frames.shift();
    }

    this.frameCount++;
  }

  /**
   * 現在のFPSを取得
   */
  getFPS(): number {
    if (this.frames.length === 0) {
      return 0;
    }

    const sum = this.frames.reduce((a, b) => a + b, 0);
    return Math.round(sum / this.frames.length);
  }

  /**
   * 平均FPSを取得
   */
  getAverageFPS(): number {
    return this.getFPS();
  }

  /**
   * 最小FPSを取得
   */
  getMinFPS(): number {
    if (this.frames.length === 0) {
      return 0;
    }
    return Math.round(Math.min(...this.frames));
  }

  /**
   * 最大FPSを取得
   */
  getMaxFPS(): number {
    if (this.frames.length === 0) {
      return 0;
    }
    return Math.round(Math.max(...this.frames));
  }

  /**
   * リセット
   */
  reset(): void {
    this.frames = [];
    this.frameCount = 0;
    this.lastTime = performance.now();
  }
}

実践的な実装例

最適化された物理シミュレーションの描画

import { FPSMonitor } from './FPSMonitor';
import { OptimizedCanvasRenderer } from './OptimizedCanvasRenderer';
import { CullingRenderer } from './CullingRenderer';
import Matter from 'matter-js';

/**
 * 最適化された物理シミュレーションの描画
 */
export class OptimizedPhysicsRenderer {
  private canvas: HTMLCanvasElement;
  private renderer: OptimizedCanvasRenderer;
  private cullingRenderer: CullingRenderer;
  private fpsMonitor: FPSMonitor;
  private bodies: Matter.Body[] = [];

  constructor(canvas: HTMLCanvasElement) {
    this.canvas = canvas;
    this.renderer = new OptimizedCanvasRenderer(canvas);
    this.cullingRenderer = new CullingRenderer(
      this.renderer['ctx'],
      { x: 0, y: 0, width: canvas.width, height: canvas.height }
    );
    this.fpsMonitor = new FPSMonitor();
  }

  /**
   * ボディを追加
   */
  addBody(body: Matter.Body): void {
    this.bodies.push(body);
  }

  /**
   * 描画を更新
   */
  update(): void {
    this.fpsMonitor.recordFrame();

    // 変更された領域だけをクリア
    for (const body of this.bodies) {
      const bounds = body.bounds;
      this.renderer.markDirty(
        bounds.min.x - 5,
        bounds.min.y - 5,
        bounds.max.x - bounds.min.x + 10,
        bounds.max.y - bounds.min.y + 10
      );
    }

    this.renderer.clearDirtyRegions();

    // 視覚領域内のボディだけを描画
    for (const body of this.bodies) {
      const bounds = body.bounds;
      this.cullingRenderer.drawIfVisible(
        bounds.min.x,
        bounds.min.y,
        bounds.max.x - bounds.min.x,
        bounds.max.y - bounds.min.y,
        () => {
          this.drawBody(body);
        }
      );
    }
  }

  /**
   * ボディを描画
   */
  private drawBody(body: Matter.Body): void {
    const ctx = this.renderer['ctx'];
    ctx.fillStyle = '#FF6B9D';
    ctx.beginPath();

    if (body.circleRadius) {
      ctx.arc(body.position.x, body.position.y, body.circleRadius, 0, Math.PI * 2);
    } else {
      const vertices = body.vertices;
      ctx.moveTo(vertices[0].x, vertices[0].y);
      for (let i = 1; i < vertices.length; i++) {
        ctx.lineTo(vertices[i].x, vertices[i].y);
      }
      ctx.closePath();
    }

    ctx.fill();
  }

  /**
   * FPSを取得
   */
  getFPS(): number {
    return this.fpsMonitor.getFPS();
  }
}

まとめ

Canvas APIを使った描画を最適化することで、以下のような効果が得られます:

  1. 60FPSの維持: 滑らかなアニメーションを実現
  2. メモリ使用量の削減: メモリリークを防止
  3. バッテリー消費の削減: モバイル端末での動作を改善
  4. ユーザー体験の向上: 快適な操作感を提供

主な最適化テクニック:

  • 再描画範囲の最小化: 変更された部分だけを再描画
  • オフスクリーンキャンバスの活用: 複雑な描画を効率化
  • 描画コンテキストの再利用: 設定の変更を最小化
  • レイヤー分離: 背景と前景を分離
  • バッチ描画: 描画操作をまとめて実行
  • 視覚領域外の描画をスキップ: 不要な描画を回避
  • メモリ管理: 画像やパスのキャッシュ
  • パフォーマンス測定: FPSの監視

これらの最適化テクニックを組み合わせることで、高性能なCanvas描画を実現できます。

関連記事

関連ツール

関連記事