高校数学で描くマンデルブロ集合

高三 N 数学JavaScript

はじめに

お久しぶりです。2019年に最後のブログを投稿してから3年以上が経ったようなのですが、当時中学2年だった私もいつの間にか高校三年になってしまいました。物理部がブログではなくTwitterを積極的に使うようになってからは、このウェブサイトも部誌の電子版の公開以外で使うことはほとんどなくなったのです。浅野を卒業する前にもう一度くらいはブログ記事を執筆しておきたいと思い、今回キーボードをたたいています。 前置きはさておき、今回は久しぶり(そりゃそうだろ)に技術的な記事になります。

対象読者

日本の高校一年生相当以上の基本的な数学の知識を有している人。必要な知識を前半部分で解説し、高校一年生、あるいは中学生程度でもある程度は理解できるように書いているつもりです。

加えて、基本的なプログラミングの知識を有している人。今回はJavascriptを使用しますが、for文、変数などの一般的な文法が分かっていればおおよそ意味はつかめると思います。

マンデルブロ集合とは

マンデルブロ集合というものを知っていますか?実態はただの複素数の集合なのですが、よくこのような写真の形でウェブ上に共有されることがあります。

※集合とは

日本では「数学A」で学習する概念。 数やその他さまざまなものの集まりのこと。集合を構成する「要素」は数だけでなく、(たぶん)確率や式であったり、数学以外の分野に集合の考え方を応用すれば「単語」や「情報」であるパターンもある(と思う)。 マンデルブロ集合は、数、そのなかでも複素数の集合である。つまり、マンデルブロ集合の中には(\(-0.2+0.5i\))や(\(0.1-0.3i\))などの様々な複素数が大量に収納されている。(複素数については後程解説します。)

マンデルブロ集合の画像
一部を拡大したマンデルブロ集合の画像

画像出典:Wikimedia Commons(CC BY-SA 3.0)

下の画像は一部を拡大したもの。

このように、マンデルブロ集合をある規則にしたがって画像に落とし込むと、非常に美しい幾何学模様をみせるため、YouTubeをはじめインターネット上にはマンデルブロ集合を描画した映像がたくさん投稿されています。

この記事では、このようなマンデルブロ集合の写真を、高校数学の知識だけで描画することを試みます。なお、今回は上記の写真や映像のようなカラフルな色付けは行いません

マンデルブロ集合の実態は、以下の漸化式が無限大に発散しないような複素数\(C\)の集まりです。(より正確な定義を後程記載)

\[ \begin {cases} Z_0 = 0\\ Z_{n+1} = {Z_n}^2 + C \end {cases} \]

このように定義されるマンデルブロ集合を構成する各複素数を複素数平面上に黒い点としてプロットしていくと、おおよそ上記の写真のような画像が得られます。

さて、一応ここまで高校数学の用語だけを用いて解説してきましたが、まだ該当範囲を学んでいない人にとってはちんぷんかんぷんだと思いますので、ここから一つ一つ噛み砕いて理解していきましょう。

ここから下は、複素数、複素数平面、漸化式、極限についての基礎知識の解説が続きます。これらを既に理解している方は、「マンデルブロ集合の要素を調べる」の章まで読み飛ばして頂いて構いません。

複素数(数学Ⅱで学習)とは

中学三年で習う平方根(ルート)を思い出してください。\(2\times2=4\)であるとき、\(4\)のルートすなわち\(\sqrt4\)は\(2\)となります。同様に\(3\times3=9\)なので、\(\sqrt9=3\)です。このように、ある数に対して、2乗することでその「ある数」となるような「別のある数」を求めるのがルートの計算です。

なお、\((-2)\times(-2)=4\)でもあるため、正確には\(\sqrt4=\pm2\)となります。また、\(\sqrt2\)などの値もコンピューターなどを使うことでおおよそ\(\pm1.41421356\)という値(近似値)を求めることが出来ます。実際に電卓でこの数を2乗してみれば、約2(1.999999993…くらい)になることが確認できると思います。

虚数の定義

さて、ここで負の数のルートを考えてみます。 \(\sqrt{-1}\)はいくつでしょうか?

\(\sqrt1\)であれば答えは\(\pm1\)、すなわち\(1\)と\(-1\)ですが、2乗して\(-1\)となるような数は存在するのでしょうか?

はい、そんな数は存在しません。正確には、実数(整数、分数&少数、ルートや円周率などの無理数)の中には存在しません。そこで、先ほど2乗して\(2\)になるような、少数で正確に表せない数を\(\sqrt2\)という記号で表したように、2乗して\(-1\)になるような数を\(i\)という記号を用いて考えることになったのです。つまり、\(i^2=-1\)であり、\(\pm i=\sqrt{-1}\)です。このような、2乗して負の数になるような数を、「虚数」と呼びます。

この、\(\pm i =\sqrt{-1}\)で定義される数\(i\)には、「虚数単位」という名前がついています。\(\pm i\)とあるように、この方程式の解はプラスマイナスの2つがありますが、虚数単位はそのどちらか片方のみを表します(計算の途中でプラスとマイナスをごっちゃにして考えなければどちらでも問題ない)。

虚数単位を使うことで、\(\sqrt{-1}\)だけでなく負の数のルートをなんでも表すことが出来るようになります。

例えば\(\sqrt{-5}\)は\(\sqrt{-5}=\sqrt{-1}\times\sqrt5\)と書き換えられますから、\(\sqrt{-5}=\pm\sqrt5i\)です。同様に\(\sqrt{-4}=\pm2i\)、\(\sqrt{-12}=\pm2\sqrt3i\)という風に表すことが可能です。

二次方程式の解の公式で、\(x^2+bx+c=0\)の解\(x=\frac{-b\pm\sqrt{b^2-4ac}}{2ac}\)のルートの中身\((b^2-4ac)\)が負の数になると「この二次方程式は実数解をもたない」だと言ったり、正の場合は「異なる実数解を2つもつ」というフレーズが出てきたりするように、ことあるごとに実数解という面倒な言葉を使うのは、ルートの中身が負の数であった場合には虚数解(複素数解)があるということの裏返しだとも考えられますね。

複素数の定義

先ほど定義した虚数単位\(i\)と2つの実数\(a,b\)を用いて、\[Z = a+bi\]と表すことの出来る数を「複素数」と呼びます。例えば\(3+4i\)という風に表される数のことです。

「\(5\)」のような普通の実数も、\(a=5,b=0\)を代入してみれば\(Z=5+0i=5\)と表せるので、複素数の一種と考えることが出来ます。逆に「\(\sqrt{-25}\)」のような虚数(「純虚数」と呼びます)も、\(Z=0+5i=5i\)と表せるので当然複素数の一部です。

ちなみに、このときに\(a\)の項を「実部」、\(bi\)の項を「虚部」と呼ぶことがあるので覚えておきましょう。

今までに出てきたいろいろな数のカテゴリー(体系)をまとめてみました。

複素数の計算は、普通の一次多項式の計算と全く同じように行うことが出来ます。 例えば\((1+2i)+(3+2i)\)という計算は、そのまま\(4+4i\)という風に計算できますし、\((1+2i)^2\)のような場合は展開公式をそのまま使って\((1+4i+4i^2)\)となります。\(i^2=-1\)なので、最終的には\(-3+4i\)です。

複素数平面(数学Ⅲで学習)とは

数直線というものがありますよね。

数直線

一定間隔に引いた目盛りに数を割り当てて、ある数がどの位の大きさなのかを比較しやすくしたものです。複素数を数直線上に描くことを考えてみましょう。 例えば、複素数\(1+2i\)はどこにあるでしょうか?

残念ながら、先ほどの「さまざまな数」の図で示したように、実数の中に\(1+2i\)のような複素数は含まれません。そのため、実数を描いている数直線上に複素数を描くことは出来ません。 ですが、\(Z=a+bi\)であらわされる複素数\(Z\)のうち、\(a\)部分だけなら実数なので、これだけなら数直線上にも描くことが出来ます。

複素数平面は、これに加え、虚数単位\(i\)の係数\(b\)を、数直線と垂直な方向に新たに作った軸上に描くことで、複素数\(Z=a+bi\)を\(ab\)平面上に描くことが出来るようにした平面のことを言います。\(Z=X+Yi\)と考えれば、\(XY\)平面の点\((X,Y)\)に点を描くという単純な作業になります。

ということで、複素数\(1+2i\)は、複素数平面上ではこのような場所にあります。

複素数平面

この時、\(a\)軸のことを実軸、\(b\)軸のことを虚軸といいます。

複素数をこのような平面上に描くことは、ド・モアブルの定理などにより複素数が図形的意味を持つことになるため非常に便利なのですが、マンデルブロ集合の描画においてあまり重要ではなく、解説が非常に長くなるためここではその解説は省きます。詳しくは、数学Ⅲの教科書を読んでみてください。

複素数の絶対値

実数で「絶対値」というものを考えたように、複素数にも「絶対値」の概念が存在します。複素数の絶対値の概念はマンデルブロ集合において重要な意味を持つので、ここで理解しておきましょう。

実数における絶対値は、「\(|-2|=2\)」や「\(|3|=3\)」、「\(|-\sqrt{2}|=\sqrt{2}\)」というように、数をすべて正の数にするような計算だとして覚えている人も多いと思います。しかし、あえて「符号を外す」とせずに、あくまで「原点からの距離」なんだという考え方を聞いたことがある人もいるのではないのでしょうか。絶対値を「原点からの距離」とする定義は、複素数の絶対値や別単元のベクトルの絶対値を考えるうえで有用です。

複素数平面において、複素数の絶対値を「原点からの距離」だとして考えてみましょう。 複素数\(1+2i\)と原点との距離\(|1+2i|\)は、先ほどの図より、三平方の定理を用いて求められます。 よって、\(|1+2i|=\sqrt{1^2+2^2}=\sqrt5\)となります。 より一般的に、複素数\(a+bi\)の絶対値は、\(|a+bi|=\sqrt{a^2+b^2}\)で求めることが出来ます。

数列と漸化式(数学Bで学習)とは

\({1,3,5,7,9,11,…}\)というような数の並びのことを「数列」と呼びます。この数列は1から始まり、2ずつ増えていくような数列(等差数列)です。このとき、数列の各要素を「項」といい、特に一番最初の項(この場合は1)を「初項」と呼びます。 数列は、\({a_n}={1,3,5,7,9,11,…}\)と表されることもあります。このとき、\(a_1\)は初項\(1\)を意味し、\(a_2\)と書けば2個目の項、すなわち\(3\)のことを意味します。

数列は先ほどのように各項を順番に一覧にして表すほかに、「一般項」を用いて表すことがあります。 先ほどの数列\({a_n}\)の一般項は\(a_n=2n-1\)です。一般項は\(n\)番目の項を\(n\)を用いて表すことが出来るように一般化した式のことで、一般項の\(n\)に任意の数を代入すれば、そのときの\(a_n\)の値を求めることが出来ます。

例えば、先ほどの数列\({a_n}\)の初項(1番目の項)は\(1\)でしたが、一般項\(a_n=2n-1\)に\(n=1\)を代入してみると、\(a_1=2\times1-1=2-1=1\)となり、実際に1番目の項を計算で求めることが出来るようになっています。

数列のもう一つの表し方が、「漸化式」を使う方法です。先ほどの数列\({a_n}\)を漸化式を用いて表すと以下のようになります。 \[ \begin{cases} a_1=1\ a_{n+1}=a_n+2 \end{cases} \] 漸化式は、\(n\)番目の項と\(n+1\)番目の項との関係性を式で表すことで数列を表す方法です(nとn+2、nとn+1とn+2など特殊なパターンもある)。 数列\({a_n}\)は項を重ねるごとに2ずつ増えていくので、\(a_{n+1}\)(\(n+1\)番目の項)はひとつ前の項\(a_n\)(\(n\)番目の項)よりも2大きくなります。それを式であらわしたのが2番目の式\(a_{n+1}=a_n+2\)です。この式だけではどの数字からスタートしてプラス2ずつ数えていくのかがわからないので、初項\(a_1=1\)の情報を追加しています。

これが漸化式です。

今回扱った\({a_n}\)のように、一般項を求めることが出来る漸化式もある一方で、ほとんどの漸化式は漸化式のまま計算せざるを得ず、一般項を求めることは困難です。一般項が分かっていれば、例えば100個目の項を求めたいときには一般項に100を代入して計算するだけで簡単に100個目の項を求めることが出来ますが、漸化式しか分かっていない数列で100個目の項を求めようとすると、漸化式に一つ前の項を代入して新しい項を求めていく計算を99回も繰り返さなくてはなりません。

無限大の極限(数学Ⅲで学習)について軽く解説

極限の考え方についてかる~~く解説します。

ちなみに、無限大ではなく0に近づける極限については数学Ⅱの微分法の単元で学習します。

極限の計算というのは、以下のようなものです。

\[\lim_{x\to\infty}{\frac{1}{x}}=0\]

limというのは英語の「limit」の略です。この計算では、\(x\)の値をどんどん大きくしていって、無限大まで大きくしていくと(\(x\to\infty\))、関数\(\frac{1}{x}\)の値はいくつになるのか、ということを考えています。

\(\frac{1}{1}\)は1ですよね。分母を少し大きくして、\(\frac{1}{2}\)は0.5です。さらに分母を大きくして、\(\frac{1}{10}\)は0.1となります。もっともっと分母を大きくして、例えば\(\frac{1}{1000}\)ではかなり小さくなって、0.001になります。同様にしてどんどん分母\(x\)を大きくして、無限大まで近づけていけば、\(\frac{1}{x}\)は次第にゼロになっていくだろう、というのが今回の計算の「雰囲気」です。

このようなとき、関数\(\frac{1}{x}\)は\(x\to\infty\)の極限で0に収束する、と言います。

逆に、

\[\lim_{x\to\infty}{\frac{x}{10000}}=\infty\]

というような計算では、はじめは\(\frac{x}{10000}\)の値は1よりも小さな数であるものの、次第に\({\frac{1000…..0000}{10000}}\)というように分子がどんどん大きくなっていくから、結局\(x\)が無限大まで大きくなってしまえば\(\frac{x}{10000}\)の値も無限大になるだろう、という風に考えます。

このようなときには、関数\(\frac{x}{10000}\)は\(x\to\infty\)の極限で無限大に発散する、と言います。

このように、普通の計算では扱えない、無限大のような数における関数の値を考えるのが、極限の考え方です。

マンデルブロ集合の要素を調べる

さて、マンデルブロ集合の定義をもう一度正確に確認してみます。

マンデルブロ集合は、以下の漸化式で表される複素数列\(Z_n\)の絶対値\(|Z_n|\)が\(n\to\infty\)の極限で無限大に発散しないような複素数\(C\)の集合 \[\begin {cases}Z_0 = 0 \\ Z_{n+1} = Z_n^2 + C\end {cases}\]

つまり、ある複素数\(C\)がマンデルブロ集合に含まれるかどうかを調べるには、\(C\)を\(Z_n\)の漸化式にあてはめて、そのときに\(|Z_n|\)が無限大に発散するかしないかを調べればよさそうです。

実際に漸化式に値を代入して計算する

試しに、\(C=0+0i(=0)\)という複素数がマンデルブロ集合に含まれるかを調べてみましょう。漸化式に代入すると

\[\begin {cases}Z_0 = 0 \\ Z_{n+1} = {Z_n}^2 + 0\end {cases}\]

実際に計算してみます。

\(Z_0=0, \\ Z_1={Z_0}^2+0=0^2+0=0, \\ Z_2={Z_1}^2+0=0, \\ Z_4=0,Z_5=0,Z_6=0,… \)

\(C=0\)のときは\(Z_n\)は明らかに常に\(0\)となり、これが発散することはなさそうです。ということで\(C=0\)はマンデルブロ集合に含まれるということが分かります。

\(C=1\)のときはどうでしょうか。漸化式に代入すると

\[\begin {cases}Z_0 = 0 \\ Z_{n+1} = {Z_n}^2 + 1\end {cases}\]

となり、実際に計算すると(ここからは実際の計算は省略します)\(Z_n = {0,1,2,5,26,677…}\)という風になりこれは恐らく発散します。

\(C=-1\)では\(Z_n={0,-1,0,-1,0,-1,…}\)となりこれは0と1の値を交互に取る(振動する)ので、収束することはあらねど無限大に発散することもありません。 このように、人間が見ればある程度は無限大に発散するかどうかを見分けることが可能ですが、コンピューターが大量の複素数について毎回判定するようなプログラムを書くことは困難です。また、今回は分かりやすい整数だったため発散の判定がしやすかったものの、実際のマンデルブロ集合には複素数も含まれており、さらにその実部、虚部の係数(\(Z=a+bi\)の\(a\)や\(b\)のこと)も細かい小数である場合がほとんどです。その場合の発散判定は困難を極めるでしょう。

「確実に発散する」ことを判定する方法

しかし、ありがたいことに、\(|Z_n|>2\)となるような項が一つでも見つかった時点で、その数列\({Z_n}\)の絶対値はいずれ必ず無限大に発散する、ということが分かっています。この証明は少し長くなるで、興味のある方はこちらのメニューを開いて確認してください。計算は少々複雑ですが、やっていること自体は簡単です。

証明(左のボタンで展開)

\(n=k\)において、初めて\(|Z_n|>2\)になったとすると、三角不等式(\(|A+B| \geqq |A|-|B|\))より、 \(|Z_{k+1}|=|{Z_k}^2+C|\geqq|{Z_k}^2|-|C|\)となる。(漸化式より) ここで、\(k=1\)のときは\(|Z_k|=|Z_1|=|0^2+C|=|C|\)、\(k>1\)のときは\(|Z_k|>2>|Z_1|=|C|\)となり、いずれにしても\(|Z_k|\geqq|C|\)となり、先ほどの式を書き換えていくと \[\begin{align} |Z_{k+1}|&\geqq|{Z_k}^2|-|C|\notag\ &\geqq|{Z_k}^2|-|Z_k|\notag\ &=|Z_k|(|Z_k|-1)\ &>|Z_k|\qquad(\because|Z_k|-1>1)\ &>2\notag \end{align}\] となる。同様にして(随所省略)、 \[\begin{align} |Z_{k+2}|&\geqq|{{Z_{k+1}}^2}|-|C|\notag\ &>|Z_{k+1}|(|Z_{k+1}|-1)\notag\ &\geqq|Z_k|(|Z_k|-1)(|Z_{k+1}|-1)\qquad(\because(1))\notag\ &>|Z_k|(|Z_k|-1)(|Z_k|-1)\qquad(\because(2))\notag\ &=|Z_k|(|Z_k|-1)^2\notag \end{align}\] これを繰り返すと、\(|Z_{k+3}|>|Z_k|(|Z_k|-1)^3\)、\(|Z_{k+4}|>|Z_k|(|Z_k|-1)^4\)、…となることがわかり、自然数\(m\)について \[|Z_{k+m}|>|Z_k|(|Z_k|-1)^m\] であることがわかる。 ここで、\(|Z_k|-1>1\)なので、\(k+m\to\infty\)すなわち\(m\to\infty\)のとき、 \[|Z_k|(|Z_k|-1)^m\to\infty\] である。よって、\(|Z_k|>2\)となる\(k\)が存在するとき、 \[\lim_{n\to\infty}|Z_n|=\infty\] となる。(証明終わり) ※Azicore様のウェブサイトを参考にしています。ありがとうございます!リンク:(https://azisava.sakura.ne.jp/mandelbrot/)

なお、\(|C|>2\)の場合、\(Z_1=0^2+C=C>2\)となり、その時点で\(Z_n\)が発散することがわかります。そのため、マンデルブロ集合に含まれる複素数\(C\)は全て必ず\(|C|\leqq2\)となります。なので、マンデルブロ集合を調べる際には\(|C|\leqq2\)の範囲でのみ計算することが一般的です。

ということで、この性質により、マンデルブロ集合に含まれる(=無限大に発散しない)ことの判定はできないものの、マンデルブロ集合に含まれない(=無限大に発散する)ことの判定はそこそこ簡単にできるようになりました。\(Z_n\)を\(n\)がある程度の大きさになるまで計算してみて、それが2よりも大きくなれば確実に\(Z_n\)は無限大に発散するのだとわかります。

また、\(Z_n\)が発散しないことを調べるのも、少々強引ではありますが\(n\)をそこそこ大きな数にしても\(Z_n\)が2を超えないという場合に、その時はもはやずっと発散しないものと判断してしまえば、正確ではないものの近似的に「\(Z_n\)が発散しないような\(C\)の集合」、すなわちマンデルブロ集合を求めることが出来ます。

このときの「そこそこ大きな数」を「しきい値」と呼ぶことにし、\(n\)がしきい値になるまで\(Z_n\)を繰り返し計算し、その間に\(Z_n>2\)となれば発散判定、その間に\(Z_n>2\)を超えることが無ければ発散しなかったと判定することにします。

描画プログラムを書く

さて、先ほどの章で考えたアルゴリズムに基づいて、マンデルブロ集合を描画するプログラムを書いてみましょう。

今回は、WEB上でマンデルブロ集合を表示させたかったので、Javascriptを用います。なお、筆者は今回初めてJavascriptをコーディングしたため、あまり推奨されていないような書き方をしているところがあるかもしれませんが、ご承知おきください。

入力された複素数がマンデルブロ集合に含まれるかどうかを判定する関数

今回のキモはここがほとんどです。先ほどの章で考えたように、与えられた複素数\(C\)に対して数列\(Z_n\)が発散するかどうかを考え、\(C\)がマンデルブロ集合に含まれる(=発散しない)場合戻り値としてtrueを返し、そうでない(=無限大に発散する)場合にfalseを返すような関数を作ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function calculate(complex,threshold){//complex=複素数, threshold=しきい値
    let z = 0;
    for (let i = 0; i < threshold; i++) {
        z = math.add(math.pow(z,2),complex);
        if (math.abs(z)>2) {
            return false; //発散する
            break;
        }
    }
    return true; //収束する
}

今回、Javascript上で複素数を扱うため、math.jsという外部ライブラリを使用しました(記事の終わりにリンクを掲載しています)。引数のcomplexにはmath.jsの複素数オブジェクトを入力する必要があります。

有名なプログラミング言語であれば、何かしら複素数など高度な数学を扱うことのできるライブラリは存在していると思います。PythonのNumPyなんかは有名ですよね。複素数の簡単な計算には四則演算以外は使用しないので、自分で実装しても問題ないでしょう。

また、引数thresholdは先ほど定義した「しきい値」です。

書いたプログラムを解説していきます。

1
let z = 0;

ここでは初項\(Z_0=0\)を宣言しています。

1
2
    for (let i = 0; i < threshold; i++) {
        z = math.add(math.pow(z,2),complex);

forループをthreshold回だけ回し、先ほど宣言したzに\(Z_{i+1}\)(このiはfor分が繰り返されるたびに1ずつ加算されるただのカウンタ変数。虚数単位ではない。)を代入しています。

math.add(a,b)a+bの計算、math.pow(a,b)a^bの計算をそれぞれ行う、math.jsの関数です。このプログラムが漸化式\(Z_{n+1}={Z_n}^2+C\)を表していることが分かると思います。

1
2
3
4
    if (math.abs(z)>2) {
        return false; //発散する
        break;
    }

math.abs(a)aの絶対値を求めるmath.jsの関数です。\(|Z_n|>2\)の場合に発散する判定を行う処理を行っています。発散する場合complexはマンデルブロ集合には含まれないので、戻り値としてfalseを帰しています。

3行目のbreak;はforループを強制的に抜け出す処理です。発散することが明らかになった以上、さらに\(Z_n\)を求めていく必要はないためここで終了しています。

1
return true;

しきい値まで\(Z_n\)を求めきった後、それでもまだ\(|Z_n|>2\)とならない場合、\(|Z_n|\)は発散しないと判断して戻り値trueを返しています。

描画したい範囲の全ての複素数についてマンデルブロ集合に含まれるかどうかを調べる

描画したい範囲の全ての複素数について、マンデルブロ集合に含まれるかどうかを今作った関数を用いて判定し、その結果(先ほどの関数でtrueもしくはfalseが返ってくる)を配列に保存しておくような関数を作ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function mandelbrot(centerre,centerim,magnification,threshold){

    //width,heightにはHTMLのcanvas要素の縦横ピクセル数を代入済み

    if (width>height){//画面の縦幅に描画する集合の範囲の縦幅を合わせる
        var cpp = 4/height/magnification;// 座標 per ピクセル
        var initre = centerre - cpp*(width/2);
        var initim = centerim + cpp*(height/2);
    }else{//画面の横幅に描画する集合の範囲の横幅を合わせる
        var cpp = 4/width/magnification;// 座標 per ピクセル
        var initre = centerre - cpp*(width/2);
        var initim = centerim + cpp*(height/2);
    }

    var pos;

    for (let i = 0; i < width; i++) {
        man_set.push([]);
        for (let j = 0; j < height; j++) {
            pos = math.complex(
                initre+cpp*i,
                initim-cpp*j
                );
            universal_set.push(pos);
            man_set[i].push(calculate(pos,threshold));
        }
    }
}

画面の中心の複素数座標を指定することで描画範囲を指定するような仕様にしたかったため、そこから左上の座標を計算する必要ができコードがやや複雑になりました。 引数はそれぞれcenterreが中心部の座標の実部、centerimが中心部の座標の虚部、magnificationが倍率、thresholdが「しきい値」です。 それではこの関数も上から順に処理を説明していきます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    //width,heightにはHTMLのcanvas要素の縦横ピクセル数を代入済み

    if (width>height){//画面の縦幅に描画する集合の範囲の縦幅を合わせる
        var cpp = 4/height/magnification;// 座標 per ピクセル
        var initre = centerre - cpp*(width/2);
        var initim = centerim + cpp*(height/2);
    }else{//画面の横幅に描画する集合の範囲の横幅を合わせる
        var cpp = 4/width/magnification;// 座標 per ピクセル
        var initre = centerre - cpp*(width/2);
        var initim = centerim + cpp*(height/2);
    }

中心部の座標、描画先の画面(今回はHTMLのcanvas要素)の縦横の画素数、拡大倍率をもとに、描画先の画面の左上端の座標を計算しています。

マンデルブロ集合全体は(\(|Z_n|\leqq2\)なので)正方形の範囲に収まるため、画面が横長の場合は、画面の縦いっぱいに拡大して表示するようにし、それ以外、すなわち画面が縦長か正方形の場合は画面の横いっぱいに拡大して表示するよう場合分けしたうえで計算しています。

ここの計算は説明が面倒なのでプログラムを読める人は自力で解釈してください(すみません)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    var pos;

    for (let i = 0; i < width; i++) {
        man_set.push([]);
        for (let j = 0; j < height; j++) {
            pos = math.complex(
                initre+cpp*i,
                initim-cpp*j
                );
            universal_set.push(pos);
            man_set[i].push(calculate(pos,threshold));
        }
    }

計算する座標を一時的に保存しておくため、変数posをはじめに定義しています。

配列man_setは二次元配列です。二次元の平面である複素数平面と対応させています。

forループを二重に回しながら、ループ毎にposに新しい複素数座標を代入し、先ほど定義したcalculate関数でその複素数がマンデルブロ集合に含まれるかどうかを調べ、結果を二次元配列man_settrueもしくはfalseの形で保存しています。

posに代入しているのはmath.jsの複素数オブジェクトです。

これで、配列man_setには画面の縦ピクセル数×横ピクセル数個の大量の複素数についてそれぞれ「マンデルブロ集合に含まれるかどうか」が保存されるようになります。

なお、man_setはこの関数の外でグローバル変数として既に定義しているので、あとで他の関数の中からでも呼び出すことが出来ます。

できればC++のポインタのような機能をつかって、参照渡しのようなものを実現したかったです。おそらくなにか方法はあるのでしょう…。調べてみると「Javascriptには参照渡しも値渡しも存在しない」だの「参照の値渡しである」だのという記述があり、余計わからなくなりました。

指定範囲のマンデルブロ集合を保存した配列を実際に描画する

ここからはもう簡単です。先ほど、配列man_setに描画先画面の全画素に対応する各複素数がマンデルブロ集合に含まれるかどうかを保存したので、あとはその配列全体を読んで、1ピクセルずつ色を塗っていくような関数を作るだけです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

function draw_set(set){
    for (let i = 0; i < width; i++) {
        for (let j = 0; j < height; j++) {
            if (set[i][j]){
                ctx.fillRect(i,j,1,1);
            }
        }
    }
}

上から説明していきます。

1
2
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

描画先のcanvas(“canvas”というIDを設定済み)を指定し、そのcanvasに対して描画をするためのコンテキストというものを取得しています。詳しくはよくわかりません…。標準で用意されているcanvasへの描画用のAPIのようなものだと思います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function draw_set(set){
    ctx.fillStyle = "black";
    for (let i = 0; i < width; i++) {
        for (let j = 0; j < height; j++) {
            if (set[i][j]){
                ctx.fillRect(i,j,1,1);
            }
        }
    }
}

引数setには二次元配列man_setを与えます。fillStyleでは描画色を設定しています。 あとはforループを二重に回し、fillRect()という関数で指定した座標にひとつづつ点を打つだけです。

仕上げのプログラム

ここまでに用意した三つの関数を使って、実際に画面に描画するまでの処理を行う関数を作ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function draw(set){
    ctx.clearRect(0,0,800,800);
    ctx.strokeStyle = ("black");
    ctx.lineWidth = 5;
    ctx.strokeRect(0,0,canvas.width,canvas.height);
    draw_set(man_set);
}

function init(){
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    width = canvas.width;
    height = canvas.height;
    mandelbrot(0,0,1,30);
    draw(man_set);
}

window.addEventListener("load",init);

上から説明していきます。

1
2
3
4
5
6
7
function draw(set){
    ctx.clearRect(0,0,800,800);
    ctx.strokeStyle = ("black");
    ctx.lineWidth = 5;
    ctx.strokeRect(0,0,canvas.width,canvas.height);
    draw_set(man_set);
}

必要ないかもしれませんが、マンデルブロ集合を描画する作業や枠線を描く作業などを一括で行う関数を作っておきました。座標軸を描き加えたりする場合には、ここにそのような処理を書き足すようにしようと思っています。 引数setには、マンデルブロ集合を保存した配列、man_setを渡して使用します。

1
2
3
4
5
6
7
8
9
function init(){
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    width = canvas.width;
    height = canvas.height;
    mandelbrot(0,0,1,30);
    draw(man_set);
}
window.addEventListener("load",init);

init()関数は、WEBページが読み込まれたとき、一番最初に一度だけ実行される関数です。window.addEventListener("load",init)というところで、そのことを指定しています。 init関数の中でcanvas.width及びcanvas.heightにWEBページの画面サイズを代入し、画面いっぱいにcanvasの要素を広げて表示するようにしています。変数width及びheightにも、画面のサイズを代入し直しています。 そして、先ほど定義したmandelbrot()関数で、マンデルブロ集合を保存した配列を作成します。今回は、引数に、中心の座標(0,0)、倍率(1倍)、発散判定を行うときの「しきい値」(30回)を指定して計算しています。 最後に、前項で定義したばかりのdraw()関数に、マンデルブロ集合を保存した配列man_setを渡し、マンデルブロ集合を描画して完了となります。

実際に動かしてみる

ここまでに書いたJavascriptのコードを`script.js`などの名前で保存し、以下のようなHTMLファイルを新たに作成します。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!DOCTYPE html>
<html>
    <head>
    </head>
    <body>
        <canvas id="canvas" width="100%" height="100%"></canvas>
        <script type="text/javascript" src="math.js"></script>
        <script type="text/javascript" src="script.js"></script>
    </body>
</html>

実際は&lt;link&gt;要素でCSSを適用したりしていますが、最低限これだけのHTMLが書かれていれば、先ほどのJavascriptコードでマンデルブロ集合を描画することが出来ます。 以下の画面で実際に描画プログラムを動かしているのでご確認ください。なお、ここではブログに埋め込むため一部を書き換えています。

おわりに・次回予告

最後まで読んでいただきありがとうございました! 次回は、マンデルブロ集合描画の高速化と、画像の着色をやってみようと思っています。実現するかはわかりませんが、お楽しみに。

参考文献

その他のリンク

次へ物理部の歴史を探る>
前へAviUtlについて、導入など>