Web WorkersとOffscreenCanvasをマスターする:重い計算と描画処理をオフロードして滑らかな60 FPSを実現

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

背景と導入の理由

ブラウザは、JavaScriptの実行、レイアウトの再計算、スタイルの解決、ガベージコレクション、画面の再描画などをすべて単一のスレッド(メインスレッド)上で処理します。滑らかな60 FPS(フレーム毎秒)を維持するために許される時間は極めて厳しく、1フレームあたりわずか16.67ミリ秒しかありません。この制限時間を超えてしまうと、瞬時にフレーム落ちが発生します。

負荷の高い処理を実行すると、この処理時間はあっという間に消費されます。例えば45MBのCSVファイルのパース、画像ピクセルデータの加工、フロッキングアルゴリズムのシミュレーションなどは、簡単にCPUを150〜500ミリ秒ほど占有してしまいます。その間、ページは完全にフリーズします。入力イベントはキューに溜まり、CSSアニメーションはカクつき、ユーザー体験を損なう深刻な描画の引っかかり(ジャンク)が発生します。

Web Workersは、純粋な計算処理をバックグラウンドスレッドへ移行させることで、この課題の一部を解決しました。しかし、WorkerからはDOMに直接アクセスできないという厄介な制約が残されていました。従来は、Workerで生成した生の画像バッファをpostMessage経由でメインスレッドに送り返す必要があり、UIスレッド側で最終的なctx.drawImage()の呼び出しを実行せざるを得ませんでした。このシリアライズを伴う往復通信自体が、レイテンシのオーバーヘッドを生んでいたのです。

OffscreenCanvas APIはこのボトルネックを根本から解消します。CanvasのレンダリングコンテキストをDOMツリーから切り離すことで、バックグラウンドWorkerから直接描画バッファへ書き込めるようになります。リアルタイムのテレメトリダッシュボード、WebGLシーン、ブラウザゲームなどにおいて、計算負荷の大きさに関係なく、ユーザー操作を常に軽快に保つことが可能になります。

環境構築

ここではViteを使って最小限のプロジェクトを構築します。Viteは標準でESモジュール形式のWeb Workerをサポートしているため、RollupやWebpackのWorkerプラグインを手動で設定する必要がありません。

ターミナルからVanilla JavaScriptのプロジェクトを作成します:

# 軽量なVanilla JavaScriptテンプレートを初期化
npm create vite@latest worker-canvas-demo -- --template vanilla

# プロジェクトディレクトリに移動
cd worker-canvas-demo

# プロジェクトの依存関係をインストール
npm install

プロジェクトのディレクトリ構成を以下のように整えます:

worker-canvas-demo/
├── index.html
├── src/
│   ├── main.js
│   └── worker.js
└── package.json

ローカルのVite開発サーバーを起動します:

npm run dev

実装と設定

ここでは実践的な負荷テストを構築します。ループ処理で3,000個のアニメーションパーティクルを描画するインタラクティブなCanvasと、クリック回数を記録するHTMLボタンを用意します。従来のシングルスレッド構成では、3,000個の座標更新によって40〜80ミリ秒の入力遅延が発生してしまいますが、Canvasの描画処理をバックグラウンドWorkerへオフロードすることで、ボタンクリックの応答時間は1ミリ秒未満に保たれます。

1. HTMLドキュメントの準備

index.htmlの内容を、Canvasコンテナとレスポンス検証用のUIトリガーに置き換えます:

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Worker & OffscreenCanvas デモ</title>
    <style>
      body {
        font-family: system-ui, -apple-system, sans-serif;
        display: flex;
        flex-direction: column;
        align-items: center;
        background: #0d1117;
        color: #f0f6fc;
        margin: 0;
        padding: 24px;
      }
      canvas {
        border: 1px solid #30363d;
        border-radius: 8px;
        background: #010409;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
      }
      .controls {
        margin-top: 20px;
        display: flex;
        gap: 16px;
        align-items: center;
      }
      button {
        padding: 10px 20px;
        font-size: 15px;
        font-weight: 600;
        cursor: pointer;
        background: #238636;
        border: 1px solid rgba(240, 246, 252, 0.1);
        border-radius: 6px;
        color: #ffffff;
        transition: background-color 0.1s ease;
      }
      button:hover {
        background: #2ea043;
      }
      button:active {
        background: #1f702e;
      }
      span {
        color: #8b949e;
        font-size: 14px;
      }
    </style>
  </head>
  <body>
    <h1>バックグラウンドスレッド パーティクルシミュレーター</h1>
    <canvas id="viewport" width="800" height="500"></canvas>
    <div class="controls">
      <button id="counterBtn">クリック数: 0</button>
      <span>すばやく連打してみてください。UIの操作は一切ブロックされません。</span>
    </div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

2. メインスレッド側のコントローラー実装

src/main.js内で、transferControlToOffscreen()を使用してCanvasの制御権を切り離します。その後、そのオブジェクトをWorkerスレッドへ直接渡します。

重要な点として、worker.postMessage()の第2引数にTransferableオブジェクトとしてOffscreenCanvasの参照を渡します。これにより、負荷の大きい構造化クローンを回避し、メモリの基盤となる所有権そのものを転送できます。

// src/main.js

const canvas = document.getElementById('viewport');
const counterBtn = document.getElementById('counterBtn');
let count = 0;

// 高速カウンター:UIスレッドがイベントを即座に処理できるかを検証
counterBtn.addEventListener('click', () => {
  count += 1;
  counterBtn.textContent = `クリック数: ${count}`;
});

// ガード節:ブラウザがOffscreenCanvasの転送をサポートしているか確認
if (!('transferControlToOffscreen' in canvas)) {
  counterBtn.disabled = true;
  alert('お使いのブラウザはOffscreenCanvasをサポートしていません。');
} else {
  // DOMから制御権を切り離す。これ以降メインスレッドでgetContext()を呼び出すとエラーが発生する
  const offscreen = canvas.transferControlToOffscreen();

  // ViteのESモジュール解決を利用してバックグラウンドWorkerをインスタンス化
  const renderWorker = new Worker(
    new URL('./worker.js', import.meta.url),
    { type: 'module' }
  );

  // Transferable配列を経由してCanvasの所有権をWorkerスレッドへ転送
  renderWorker.postMessage(
    {
      type: 'INIT',
      canvas: offscreen,
      width: canvas.width,
      height: canvas.height,
    },
    [offscreen]
  );
}

3. バックグラウンド描画Workerの実装

src/worker.jsでは、メッセージを受信してOffscreenCanvasを取り出し、その2Dコンテキストを取得した上でrequestAnimationFrameループを開始します。近年の主要ブラウザ(Chrome 69以降、Firefox 105以降、Safari 16.4以降)は、Dedicated Workerコンテキスト内でのrequestAnimationFrameの直接実行に完全対応しています。

// src/worker.js

let ctx = null;
let width = 0;
let height = 0;
const PARTICLE_COUNT = 3000;
const particles = [];

class Particle {
  constructor(w, h) {
    this.x = Math.random() * w;
    this.y = Math.random() * h;
    this.vx = (Math.random() - 0.5) * 3;
    this.vy = (Math.random() - 0.5) * 3;
    this.radius = Math.random() * 2 + 1;
  }

  update(w, h) {
    this.x += this.vx;
    this.y += this.vy;

    if (this.x < 0 || this.x > w) this.vx = -this.vx;
    if (this.y < 0 || this.y > h) this.vy = -this.vy;
  }

  draw(context) {
    context.beginPath();
    context.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    context.fillStyle = '#38bdf8';
    context.fill();
  }
}

function render() {
  // 半透明の塗りつぶしにより効率的な残像エフェクトを生成
  ctx.fillStyle = 'rgba(1, 4, 9, 0.25)';
  ctx.fillRect(0, 0, width, height);

  for (let i = 0; i < particles.length; i++) {
    particles[i].update(width, height);
    particles[i].draw(ctx);
  }

  // Workerスレッド内で直接スケジュールされるレンダーループ
  requestAnimationFrame(render);
}

self.onmessage = (event) => {
  const { type, canvas, width: w, height: h } = event.data;

  if (type === 'INIT') {
    width = w;
    height = h;
    ctx = canvas.getContext('2d');

    for (let i = 0; i < PARTICLE_COUNT; i++) {
      particles.push(new Particle(width, height));
    }

    requestAnimationFrame(render);
  }
};

動作確認とパフォーマンス計測

実行中のViteデモをChrome、Firefox、またはEdgeで開き、スレッドの分離と描画の安定性を測定します。

1. 高負荷下でのイベント遅延をテストする

3,000個のパーティクルがCanvas上を移動する様子を確認しながら、「クリック数: 0」ボタンをすばやく連打します。Workerが16ミリ秒ごとに6,000回もの座標変更を計算し、3,000回ものパス描画コマンドを実行している最中であっても、ボタンのクリックハンドラーは2ミリ秒未満で発火します。入力の遅延は一切ありません。

2. Chrome DevToolsでワークロードをプロファイリングする

Chromeのプロファイラーを使用してスレッドの分離を検証します:

  1. F12キーを押してDevToolsを開き、Performanceタブを選択します。
  2. Recordをクリックし、カウンターボタンを3秒間連打してからStopをクリックします。
  3. スレッドのタイムラインを確認します:
  • Main: スクリプト実行時間はほぼゼロを示します。タスクは1ミリ秒未満で完了し、約97%がアイドル時間となるため、DOMイベントの処理やスタイル適用に十分な余裕が確保されます。
  • Dedicated Worker: パーティクル計算とarc()描画のブロックが継続的に実行されていますが、UI側にLong Task(50ミリ秒以上)の警告フラグを発生させることはありません。

3. フレームレートの安定性を確認する

Ctrl + Shift + P(macOSではCmd + Shift + P)でコマンドメニューを開き、「Show Rendering」と入力してEnterキーを押します。下部パネルでFrame Rendering Statsを有効にします。リアルタイムのGPU HUDが表示され、フレーム落ちの急増もなく安定して60 FPSが維持されていることを確認できます。

Share: