マンデルブロ集合を描画したら、WebWorkerによる並列計算とJITコンパイラの実力を知った話【JavaScript】【初学者】
タイトルの通りの内容となります。
はじめに
記事を開いてくれてありがとうございます!JavaScript/TypeScriptをほんの少しだけ触ったことがあるだけのただのニートです。
最近、Youtubeのとあるチャンネル(青3茶1さん)の動画で、CGフラクタルをみて「なんだこれは!?めちゃくちゃ面白いぞ!」と、とても興味が湧きました。
そして、その動画の途中で出てくるあの象徴的な形、あれを自分のPCで描画してみたい。そう強く思いました。
早速作ってみますが、その前に、マンデルブロ集合が何か簡単に説明します。数学好きな方には有名すぎて知らない人の方が少ないかもしれませんが…(そうでなくても一度は見たことがあるかもしれない)
マンデルブロ集合って何?
マンデルブロ集合は、複素数平面上の点c
に対して、次のような漸化式で定義される数列:
この数列 c
の集まりです。
実際に作って表示したマンデルブロ集合の画像
…と式だけ見せられても分かりづらいですよね。もう少しやさしく説明してみます。
複素数ってなに?
まず、複素数とは「実数」と「虚数」を組み合わせた数で、a + bi
という形をしています。
-
a
は実部(日常で使うような数) -
b
は虚部(i
を含んだ数) -
i
は虚数単位で、i² = -1
という性質があります
複素数は「複素平面」と呼ばれる座標平面上で表すことができ、横軸が実部、縦軸が虚部になります。
マンデルブロ集合をなんとなく理解しよう
マンデルブロ集合を理解するには、実際に数を使って確かめてみるのが一番です。
- 複素平面上の点
c
を選びます(例:1 + 2i) - 初期値
z₀ = 0
から始めて、「前の値を二乗してc
を足す」操作を繰り返します
具体的には:
- z₀ = 0
- z₁ = z₀² + c = 0² + c = c
- z₂ = z₁² + c = c² + c
- z₃ = z₂² + c = (c² + c)² + c
…と続いていきます
- このとき、「この数列がどんどん大きくなっていかず、ある範囲の中にとどまる」ような点
c
を、マンデルブロ集合の一部とします。
「発散する」ってどういうこと?
ざっくり言うと、数列の値がどんどん大きくなって、∞(無限大)に向かっていってしまうことです。
実際には、途中のどこかで複素数の絶対値(原点からの距離)が「2」を超えると、その後は絶対に発散してしまうことがわかっています。なので、判定するときは「絶対値が2を超えたら発散」とみなしてOKです。
具体例
-
c = 0
z₀ = 0 → z₁ = 0 → z₂ = 0 → z₃ = 0 …
→ ずっと0のままなので、発散しない(マンデルブロ集合に含まれる) -
c = 1
z₀ = 0 → z₁ = 1 → z₂ = 2 → z₃ = 5 → z₄ = 26 …
→ どんどん大きくなるので発散(集合に含まれない) -
c = -1
z₀ = 0 → z₁ = -1 → z₂ = 0 → z₃ = -1 → z₄ = 0 …
→ 同じ値を行き来しているだけなので、発散しない(集合に含まれる)
美しい図形が生まれる理由
このルールを使って、複素平面上のたくさんの点c
について「発散するかどうか」を調べて、以下のように色をつけます:
- 発散しない点 → 黒く塗る
- 発散する点 → 「発散するまでに何ステップかかったか」で色分け
こうして描かれた図が、あの有名な「マンデルブロ集合」の形です。めちゃくちゃに複雑で美しいフラクタル模様が現れます。
おまけ:フラクタルって?
マンデルブロ集合が面白いのは、どれだけ拡大しても、似たような模様が繰り返し現れるところです。こういった構造をフラクタル構造と呼びます。
自然界でも、雪の結晶、カリフラワー、海岸線などに見られる不思議なパターンですね。
マンデルブロ集合を描画するぞ!
ということで、少し前話が長くなりましたが、JavaScriptでマンデルブロ集合を書いてみましょう。
描画するhtmlファイル
<canvas id="canvas" width="600" height="600"></canvas>
<script>
document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// 描画中心とズーム倍率
let centerX = -0.5;
let centerY = 0;
let zoom = 1;
const maxIterations = 100;
// 色の用意(HSLをRGBに変換)
const colors = [];
for (let i = 0; i < 256; i++) {
const [r, g, b] = hslToRgb(i / 256, 1, 0.5);
colors.push([r, g, b]);
}
function hslToRgb(h, s, l) {
let r, g, b;
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
return [r * 255, g * 255, b * 255];
}
function drawMandelbrot() {
const width = canvas.width;
const height = canvas.height;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const xMin = centerX - 2 / zoom;
const xMax = centerX + 2 / zoom;
const yMin = centerY - 2 / zoom;
const yMax = centerY + 2 / zoom;
const dx = (xMax - xMin) / width;
const dy = (yMax - yMin) / height;
for (let px = 0; px < width; px++) {
for (let py = 0; py < height; py++) {
let x0 = xMin + px * dx;
let y0 = yMin + py * dy;
let x = 0, y = 0;
let iteration = 0;
// 漸化式 z = z^2 + c の計算
while (x * x + y * y < 4 && iteration < maxIterations) {
let xTemp = x * x - y * y + x0;
y = 2 * x * y + y0;
x = xTemp;
iteration++;
}
const index = (py * width + px) * 4;
// 色付け(繰り返し回数に基づく)
if (iteration === maxIterations) {
// 集合内部は黒
data[index] = 0;
data[index + 1] = 0;
data[index + 2] = 0;
} else {
// 集合外部は色分け
const [r, g, b] = colors[iteration % colors.length];
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
}
data[index + 3] = 255; // 不透明
}
}
ctx.putImageData(imageData, 0, 0);
}
drawMandelbrot();
});
</script>
これをhtmlファイルとして保存して、ブラウザで開くと、実際にマンデルブロ集合が表示されると思います。
インタラクション性を追加する
このままではただ図形が表示されるだけで、探索ができず、フラクタル図形の面白さが分かりません。
なので、移動と拡大縮小をできるようにしてみましょう。
インタラクション性を追加するために編集する部分
<style>
body { text-align: center; font-family: sans-serif; background: #111; color: white; }
#canvas { border: 1px solid #555; max-width: 100%; height: auto; }
+ .controls { margin-top: 10px; }
+ button { margin: 0 5px; padding: 5px 10px; font-size: 16px; }
+ #zoomLevel { font-weight: bold; margin-left: 10px; }
</style>
<canvas id="canvas" width="800" height="600"></canvas>
+
+<div class="controls">
+ <button id="zoomIn">ズームイン</button>
+ <button id="zoomOut">ズームアウト</button>
+ <span>ズーム倍率: <span id="zoomLevel">1.0</span></span>
+</div>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
+
+ const zoomLevelDisplay = document.getElementById('zoomLevel');
+ const zoomInBtn = document.getElementById('zoomIn');
+ const zoomOutBtn = document.getElementById('zoomOut');
// 初期パラメータ
let centerX = -0.5;
let centerY = 0;
let zoom = 1;
ctx.putImageData(imageData, 0, 0);
+
+ // ズーム倍率を表示
+ zoomLevelDisplay.textContent = zoom.toFixed(1);
}
- drawMandelbrot(); // 初期描画
+
+ // ズーム処理
+ zoomInBtn.addEventListener('click', () => {
+ zoom *= 1.5;
+ drawMandelbrot();
+ });
+
+ zoomOutBtn.addEventListener('click', () => {
+ zoom /= 1.5;
+ drawMandelbrot();
+ });
+ // キャンバスクリックで中心を移動
+ canvas.addEventListener('click', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ const xMin = centerX - 2 / zoom;
+ const yMin = centerY - 2 / zoom;
+ const dx = 4 / (zoom * canvas.width);
+ const dy = 4 / (zoom * canvas.height);
+
+ centerX = xMin + x * dx;
+ centerY = yMin + y * dy;
+
+ drawMandelbrot();
+ });
drawMandelbrot(); // 初期描画
これで、ズームイン・アウトに加えて、キャンバスをクリックすることで中心を移動できるようになりました。
ただ、少しズームしただけでは、縁の部分が荒くなり、フラクタル図形の面白さが十分に伝わりません。そこで、コード内の maxIterations
の値を変更してみましょう。
現代のCPUであれば、600x600のキャンバスサイズ・繰り返し回数1000回程度であれば、多少描画に時間がかかるものの、十分に高速に実行できます。
maxIterations = 1000
に設定してズームしてみてください。これまでよりも縁の細部まで詳細に描画されるようになり、さらにズームすることで、フラクタル図形ならではの奥深さが実感できるはずです。
とはいえ、ズーム倍率を上げても詳細にフラクタルを見るため、繰り返し回数を上げるほど、描画にかかる時間も長くなってしまいます。これではフラクタルをインタラクティブに楽しむには少しストレスですよね。試しに、1万とか10万とかにしてみてください。多分ブラウザが固まってしまいます。
そこで登場するのが、並列計算です。
並列計算を使って高速化しよう!
JavaScriptは基本的にシングルスレッドで動作します。つまり、1つの重い処理をしている間は、他の処理(特にUIの描画など)が止まってしまうという問題があります。
マンデルブロ集合の描画では、キャンバス上のすべてのピクセルごとに独立した繰り返し計算が必要になります。この処理は非常に計算コストが高く、ズームしたり繰り返し回数(maxIterations
)を増やすことで、描画速度が著しく低下してしまいます。
しかし逆に言えば、各ピクセルは他のピクセルと干渉しないため、これらの計算を並列化するのに非常に適しているのです。これが「マンデルブロ集合はめちゃめちゃに並列計算向き」と言われる理由です。
しかし、通常のJavaScriptは先程言った通り基本的にシングルスレッド動作です。が、JavaScriptでも本格的な並列処理を実現する手段としてWeb Worker
というものが用意されています。
Web Workerとは?
Web Worker とは、メインスレッドとは別にバックグラウンドで動作するスレッドのことで、CPUに負荷の高い処理をオフロードしても、UIや他の処理をブロックしないという特徴があります。Workerは独立したスクリプトとして実行され、メインスレッドとは postMessage
/ onmessage
によるメッセージのやりとりで通信します。
ただし、Web Workerにはいくつかの制約もあります:
DOM にアクセスできない(document, window は使えない)
ファイルは別ファイルとして分離する必要がある(モジュール型も可)
データはコピーしてやり取りされるため、大きなオブジェクトの受け渡しは慎重に
こうした制限はあるものの、マンデルブロ集合のように「大量の計算だけを行う処理」にはぴったりです。
次は、実際に Web Worker を使ってマンデルブロ集合の描画処理を並列化し、どれだけパフォーマンスが改善されるかを体験してみましょう。
ブラウザ(JavaScript)だけでここまでできるのか!と思うはずです。
Web Worker を実際に使ってみよう!
ここからは、実際に Web Worker を使う方法を見ていきましょう。
最小構成の例
まずは、基本的なWeb Workerの構成を見てみます。以下のように、Worker 用のスクリプトを別ファイル(例: worker.js)として用意し、それをメインスレッド(例: main.js)から読み込んで使います。
// 受け取ったデータに対して処理をして返す
onmessage = (e) => {
const data = e.data;
const result = data * 2; // 単純な処理
postMessage(result);
};
const worker = new Worker('worker.js');
// メッセージを受け取ったときの処理
worker.onmessage = (e) => {
console.log('Workerからの結果:', e.data);
};
// ワーカーにデータを送信
worker.postMessage(10); // → "Workerからの結果: 20"
このように、postMessage でデータを送り、onMessage で受け取って、Worker 側でも処理結果を postMessage で返すことで、非同期かつ並列に処理が行えます。
マンデルブロ集合にどう応用する?
さて、マンデルブロ集合の描画は、各ピクセルの繰り返し計算が非常に重い処理でした。ここに Web Worker を適用するには:
キャンバスを複数の**タイル(分割領域)**に分ける
各タイルを 個別の Worker に担当させる
タイルごとに計算が終わったらメインスレッドに結果(色情報)を返す
メインスレッド側でキャンバスに描画する
という流れになります。
マンデルブロ集合に適用してみる
ここからは、実際に Web Worker
を使って、マンデルブロ集合の計算処理を並列化してみましょう。
さきほど説明したようにすべての処理をメインスレッドで行うと、描画に時間がかかる上に UI の反応も悪くなってしまいましたよね。そこで、キャンバスの描画処理を複数の Worker に分担させることで、処理の高速化 + UI の応答性維持を同時に実現します。
今回の実装は、以下の2つのファイルで構成されます:
-
index.htmlの<script>内
:メインスレッド側。キャンバスの初期化や Worker の制御を担当 -
mandelbrot-worker.js
:Worker 側。実際の計算処理を担当
それでは順に見ていきましょう。
メインスレッド側
まずはキャンバスとパラメータの初期化、そして Worker の生成と通信の処理です。ここでは、CPUコア数に応じてキャンバスを横方向に分割し、それぞれの領域の計算を各 Worker に担当させます。
また、Workerは作成したらそれで終わりではなく、不要になったときは必ず terminate() を呼んで終了させる必要があります。今回の実装では initWorkers() 関数内で一度既存の Worker を全て停止し、再生成することで安全な再利用を実現しています。
コードを見る
// htmlのscriptの中に追加
+ const workerCount = navigator.hardwareConcurrency || 4;
+ let workers = [];
+ let pendingWorkers = 0;
- function drawMandelbrot() {
+ function drawMandelbrotParallel() {
+ pendingWorkers = workerCount;
+ const imageData = ctx.createImageData(canvas.width, canvas.height);
+ const rowsPerWorker = Math.ceil(canvas.height / workerCount);
+ for (let i = 0; i < workerCount; i++) {
+ const worker = workers[i];
+ const rowStart = i * rowsPerWorker;
+ const rowEnd = Math.min(rowStart + rowsPerWorker, canvas.height);
+ worker.postMessage({
+ rowStart,
+ rowEnd,
+ width: canvas.width,
+ height: canvas.height,
+ centerX,
+ centerY,
+ zoom,
+ maxIterations,
+ colors
+ });
+ }
+
+ workers.forEach((worker, i) => {
+ worker.onmessage = (e) => {
+ const { rowStart, rowEnd, resultData } = e.data;
+ const uint8Data = new Uint8ClampedArray(resultData);
+ for (let y = rowStart; y < rowEnd; y++) {
+ for (let x = 0; x < canvas.width; x++) {
+ const srcIdx = ((y - rowStart) * canvas.width + x) * 4;
+ const destIdx = (y * canvas.width + x) * 4;
+ imageData.data[destIdx] = uint8Data[srcIdx];
+ imageData.data[destIdx + 1] = uint8Data[srcIdx + 1];
+ imageData.data[destIdx + 2] = uint8Data[srcIdx + 2];
+ imageData.data[destIdx + 3] = uint8Data[srcIdx + 3];
+ }
+ }
+ pendingWorkers--;
+ if (pendingWorkers === 0) {
+ ctx.putImageData(imageData, 0, 0);
+ zoomLevelDisplay.textContent = zoom.toFixed(1);
+ }
+ };
+ });
+ }
+ function initWorkers() {
+ workers.forEach(w => w.terminate());
+ workers = [];
+ for (let i = 0; i < workerCount; i++) {
+ workers.push(new Worker('mandelbrot-worker.js'));
+ }
+ }
- drawMandelbrot();
+ initWorkers();
+ drawMandelbrotParallel();
- zoomInBtn.addEventListener('click', () => {
- zoom *= 1.5;
- drawMandelbrot();
- });
+ zoomInBtn.addEventListener('click', () => {
+ zoom *= 1.5;
+ drawMandelbrotParallel();
+ });
- zoomOutBtn.addEventListener('click', () => {
- zoom /= 1.5;
- drawMandelbrot();
- });
+ zoomOutBtn.addEventListener('click', () => {
+ zoom /= 1.5;
+ drawMandelbrotParallel();
+ });
- canvas.addEventListener('click', (e) => {
- ...
- drawMandelbrot();
- });
+ canvas.addEventListener('click', (e) => {
+ ...
+ drawMandelbrotParallel();
+ });
Worker 側
Worker 側は、渡された領域のピクセルごとにマンデルブロ集合の計算を行い、RGBA の配列としてメインスレッドに返します。
コードを見る
self.onmessage = (e) => {
const {
rowStart,
rowEnd,
width,
height,
centerX,
centerY,
zoom,
maxIterations,
colors,
} = e.data;
const xMin = centerX - 2.0 / zoom;
const xMax = centerX + 2.0 / zoom;
const yMin = centerY - 2.0 / zoom;
const yMax = centerY + 2.0 / zoom;
const dx = (xMax - xMin) / width;
const dy = (yMax - yMin) / height;
const resultData = new Uint8ClampedArray((rowEnd - rowStart) * width * 4);
for (let y = rowStart; y < rowEnd; y++) {
for (let x = 0; x < width; x++) {
const cReal = xMin + x * dx;
const cImag = yMin + y * dy;
let zReal = 0;
let zImag = 0;
let iteration = 0;
while (zReal * zReal + zImag * zImag < 4 && iteration < maxIterations) {
const temp = zReal * zReal - zImag * zImag + cReal;
zImag = 2 * zReal * zImag + cImag;
zReal = temp;
iteration++;
}
const pixelIndex = ((y - rowStart) * width + x) * 4;
if (iteration === maxIterations) {
resultData[pixelIndex] = 0;
resultData[pixelIndex + 1] = 0;
resultData[pixelIndex + 2] = 0;
} else {
const color = colors[iteration % colors.length];
resultData[pixelIndex] = color[0];
resultData[pixelIndex + 1] = color[1];
resultData[pixelIndex + 2] = color[2];
}
resultData[pixelIndex + 3] = 255;
}
}
self.postMessage({ rowStart, rowEnd, resultData }, [resultData.buffer]);
};
これで、マンデルブロ集合の描画が並列計算され、高速になりました!試しに、maxIterations
を 10000
にしてみてください。先程の通常実装より遥かに高速に描画されているはずです。(代わりに少しバグってティアリングが起きたときのような表示になったりしますが…)
1万回の繰り返し計算もすると、とてつもない倍率まで拡大しても詳細に描画されるようになります。なんなら、拡大していくと、JavaScriptの精度の限界が来て、ピクセル化してしまうところまで描画されるようになると思います。
おまけ
JITコンパイラ、エグいっす
今回のマンデルブロ集合の描画、Worker を使って並列化したことで、バリバリ高速に動くようになりました。
実はこの記事を書き始める時に、比較用に Tauri + Rust で同様のマンデルブロ描画アプリを作ってみたことがあります。Rust はコンパイル言語でネイティブバイナリに変換されるため、普通に考えれば JavaScript よりずっと高速なはず…なんですが、なんとレンダリングのタイム計測をしてみると、一部のケースでは JavaScript の方が速いという驚きの結果に。
これは JavaScript のエンジン(特に V8)に搭載されている JIT(Just-In-Time)コンパイラの最適化が効いているためです。最初の一回目は確かに少し遅いのですが、その実行ログを元に実行時に最適化されたバイナリコードにコンパイルし直すため、2回目以降はまるで別物のように速くなるんです。
つまり、今回のマンデルブロ集合の描画のような処理なら「初回ちょっと遅いけど、そこから鬼速くなる」っていうのが JIT の本領。しかもブラウザが自動でそれをやってくれるので、開発者は特に意識せずともこの恩恵を受けられるという…これは控えめに言ってヤバい。
並列計算といえば…
ここまでは、マンデルブロ集合の描画をCPUを使った並列計算で高速化してきました。ですが、並列処理といえば、その道のプロ中のプロがいますよね?
そう、GPUです。
GPU(Graphics Processing Unit)は、もともと画像処理のために作られたチップですが、その構造上 大量のコアを使って一気に同じ処理を並列に走らせるのが大得意です。今回のように、ピクセルごとに独立した計算を行うマンデルブロ集合のようなケースでは、GPUは最強の選択肢になります。
Web環境でも WebGL や WebGPU を使えば、GPU を叩いてさらに数桁レベルで速いレンダリングをすることも可能になります。実は少しだけ前に、WebGL を使ってマンデルブロ集合を描画するプログラムを書いたことがありますが、そのときはなんと反復回数を100万ぐらいにしても、フリーズせずに描画できました。(1枚レンダリングするのに時間は多少はかかったけど)
それぐらいGPUって並列計算が得意なんですよね。スペックによっては100万でもほぼリアルタイムで描画できたりするのかな?って思ってみたり。
おわりに
マンデルブロ集合について調べるときに、初めてProcessingという言語を知りました。Javaベースらしいので、使う機会があるのかは分かりませんが、ちょっとだけ触ってみたいですね。
反復回数や、並列計算モードを切り替えられたり、レンダリングタイムが表示されるものを、GitHub Pagesに公開しています。よかったら、遊んでみてください。(スマホのベンチマークにぜひ)
もう2ヶ月で初投稿から1年経つけどまだ初学者でいいよね?
あとニート脱却したいな~ では。
参考
Discussion