Go+Fyneでマンデルブロー集合を描いてみる
こんにちは、株式会社SHIFT、自動化エンジニアの水谷です。
Go言語を学んでみる連載の第5回目になります。第2回目の記事で、wxGoを使ったプラットフォーム非依存のGUIアプリ開発を試みましたが、Ubuntu上でwxGoのインストールが簡単にできなかった(ソースコードを一部変更する必要があった)こともあり、今回は別のGUIパッケージ”Fyne”を使って、プラットフォーム非依存のGUIアプリに再度挑戦してみたいと思います。
また、Go言語の要と言ってもいい機能であるゴルーチンによる並行実行による高速化についても少し試してみたのですが、なかなか面白い結果になりましたので、これについても詳しく書いてみたいと思います。
Fyneというパッケージ
Fyneは現在もアクティブに開発が進められているGUIアプリ開発用パッケージで、最新のバージョン2.0ではデータバインディング機能やアニメーション機能が追加されたり、ウィジェットの数も増えるなど、より実用的になってきています。
※FyneのGithubのURLは以下の通りです。
https://github.com/fyne-io/fyne
今回のお題はマンデルブロー集合
今回作成するアプリは、Goでの画像の描画がどれくらいの速度でできるかという個人的興味から、フラクタル図形として有名なマンデルブロー集合を描画するアプリにしてみたいと思います。
マンデルブロー集合について簡単に書いておきますと、これは、ある複素平面上の点C(0)について、C(n+1) = C(n) * C(n) + C(0)の級数演算を繰り返した時に、その値が発散するかしないかを示したマップのようなもの、と言えますでしょうか。厳密にはちょっと違うかもしれませんが、おおよそそのようなものだと思いますw
さて、そのC(0)としては、実数部も虚数部も -2.0~2.0 あたりの範囲にすると、(後で載せるような)よく見かける(?)自己相似形の不思議な形のマップが得られます。このシンプルな計算でとても不思議な絵が得られるのは興味深いですね。
Fyneのインストール
では、いつものようにディレクトリの作成を行ってから、Fyneをインストールしましょう。
ディレクトリの作成です。今回はプロジェクト名を"goMandelbrot"としますので、その名前のディレクトリを作成します。
mkdir goMandelbrot
cd goMandelbrot
続いて、"go mod init"を実行します。
go mod init goMandelbrot
そして、Fyneのインストールです。以下の6行を実行して、必要となるサブパッケージも合わせてインストールしましょう。
go get fyne.io/fyne/v2
go get fyne.io/fyne/v2/app
go get fyne.io/fyne/v2/canvas
go get fyne.io/fyne/v2/widget
go get fyne.io/fyne/v2/container
go get fyne.io/fyne/v2/layout
Fyneでの画像描画
Fyneでのウィンドウ作成は、まず下のようにメインウィンドウを作成します。
myApp := app.New()
win := myApp.NewWindow("window title")
このメインウィンドウにSetContent()メソッドでUIコンポーネントを配置します。実際には、このメソッドの引数にはコンテナオブジェクト(fyne.container)を指定するのですが、これにはUIコンポーネントを縦に積むものや横に並べるもの、グリッドに配置するものなどがあります。もちろん、コンテナの中に別のコンテナを含めることができるので、階層構造を持つUIが作成できます。
今回は、マンデルブロー集合を表示する800×800ピクセルのキャンバスオブジェクトを上に配置し、その下に複素数の範囲を指定するエディットボックスと、「Start」ボタンを配置するようにしました。コードは下のようになります。
func main() {
myApp := app.New()
win := myApp.NewWindow("Mandelbrot")
img := image.NewRGBA(image.Rect(0, 0, 800, 800))
canvasImg := canvas.NewImageFromImage(img)
canvasImg.FillMode = canvas.ImageFillOriginal
rangeRealMin := widget.NewEntry()
rangeRealMin.Text = "-1.7"
rangeRealMax := widget.NewEntry()
rangeRealMax.Text = "0.7"
rangeImaginaryMin := widget.NewEntry()
rangeImaginaryMin.Text = "-1.2"
rangeImaginaryMax := widget.NewEntry()
rangeImaginaryMax.Text = "1.2"
win.SetContent(container.NewVBox(
canvasImg,
container.NewHBox(
widget.NewLabel("Range: Real"),
rangeRealMin,
widget.NewLabel("~"),
rangeRealMax,
widget.NewLabel("Imaginary"),
rangeImaginaryMin,
widget.NewLabel("~"),
rangeImaginaryMax,
layout.NewSpacer(),
widget.NewButton("Start", func() {
realMin, _ := strconv.ParseFloat(rangeRealMin.Text, 64)
realMax, _ := strconv.ParseFloat(rangeRealMax.Text, 64)
imaginaryMin, _ := strconv.ParseFloat(rangeImaginaryMin.Text, 64)
imaginaryMax, _ := strconv.ParseFloat(rangeImaginaryMax.Text, 64)
Calc(img, realMin, realMax, imaginaryMin, imaginaryMax)
canvasImg.Refresh()
}),
),
))
win.ShowAndRun()
レイアウトの細かいスペース調整などはできないようですが、比較的ストレートにコーディングでき、書いたコードも読みやすいように見えますね。
「Start」ボタンのハンドラーは、無名関数を定義しています。実数部と可数部それぞれの範囲を、エディットボックス(Fyneでは"Entry")から読み取って(この部分のエラーチェックは省略しています)、その値で計算と描画を行うCalc()を呼び出しています。
その後、canvasImg.Refresh()をコールして、画面を更新してハンドラーは完了です。
マンデルブロー集合の計算
さて、あとは各点について級数が収束するかどうかを調べて、その結果をプロットしていく、Calc()の実装ですが、下のように作りました。
func Calc(img *image.RGBA, rmin float64, rmax float64, imin float64, imax float64) {
for y := 0; y < 800; y++ {
for x := 0; x < 800; x++ {
var C = complex(rmin+(rmax-rmin)*float64(x)/800, imin+(imax-imin)*float64(y)/800)
var V = C
var it uint8 = 0
for ; it < 100; it++ {
V = V*V + C
if cmplx.Abs(V) > 2 {
break
}
}
if it < 100 {
img.Set(x, y, color.White)
} else {
img.Set(x, y, color.Black)
}
}
}
}
この関数の引数は、描画先のキャンバスと、実数部と虚数部のそれぞれ最小値と最大値の合わせて5つです。この範囲をそれぞれ800分割し、合計16万点について、100回の級数計算で収束するかどうかを見ていきます。ここで、級数の値の絶対値が2を超えると、その後確実に発散することが知られていますので、時間短縮のため2を超えた場合はその時点で計算をやめています。逆に100回の計算後2を超えていなければ、収束すると判断するようにしています。この辺りは厳密には正しくないかもしれませんが、このアプリの動作的には十分でしょう。
初期値はC、現在の値はVというどちらもcomplex型(複素数型)を使っているので、コードはシンプルになりますね。なお、complex型を使うには、"math/cmplx"をimportしておく必要があります。
さて、級数の計算を繰り返し、絶対値が2を超えるまでの計算回数が100を下回っている(発散した)場合は、img.Set(x, y, color.White)を実行してその点を白にし、そうでない場合(収束した)場合は、img.Set(x, y, color.White)を実行して黒にしています。
実数部を-1.7~0.7、虚数部を -1.2~1.2の範囲で実行してみると、下のような結果が得られました。
色を付けてみる
級数が収束するかどうかで白か黒かに色分けしただけでは、ちょっと絵的に寂しいので、色付けをしてみます。
ネットで調べてみると、色付け方法にもいくつかの方法があるようですが、ここでは発散判定時にどれくらい(絶対値が)2より大きかったかによって、色を付けてみることにします。
RGB各チャネルを以下のような値にしてみました(かなり適当です)。
これで実行すると、以下のような絵になります。
また、範囲を実数-0.59~-0.43、虚数0.49~0.65と指定してみると、このような絵になります。かなり雰囲気は異なりますが、青色の収束している部分の形は、(かなり狭い範囲での計算にもかかわらず)先ほどの絵にあったものの相似形になっていることが分かるかと思います。
ゴルーチンを使った高速化
さて、このアプリで使っている計算部分のコードを見てみると、各点について独立して計算されていて、別の点の値の参照などは一切ありません。つまり、この計算は簡単に並列実行させることができることになります。
そこで、ゴルーチンを使って、並列実行させて、どれだけ高速化できるか試してみましょう。
せっかくですので、ゴルーチンの数もエディットボックスで指定できるようにします。その数はnGRという変数に入るようにし、その数だけゴルーチンを作成します。
演算終了のシグナルを受け取れるよう、fchannelというチャネルを作っておき、ゴルーチンは、演算終了後このチャネルに通知を送るようにしています。そして、ゴルーチンの数だけ通知が来たら再描画するという形にしています。
nGR, _ := strconv.ParseInt(numGoRoutines.Text, 10, 32)
fchannel := make(chan int)
defer close(fchannel)
for i := 0; i < int(nGR); i++ {
go CalcGR(img, realMin, realMax, imaginaryMin, imaginaryMax, i, int(nGR), fchannel)
}
for i := 0; i < int(nGR); i++ {
<-fchannel
}
どの程度の効果が出るのか、4コア(4物理コア)のCPUを搭載したマシンを使って測ってみることにします。
ゴルーチン数を1、2、4、8、16と変化させながら、それぞれ10回実行した平均を計測してみたところ、下のグラフのようになりました。
横軸は16万点の計算にかかる時間(単位はミリ秒)で、棒が短いほうが速いということになります。
グラフを見ると、予想通り効果は抜群ですね。ゴルーチンを2つ動作させて2点同時に計算することで、190msから107msに短縮、つまり44%ほど削減できたことになります。ゴルーチンを4つにすると、さらに33%短縮され、元のコードに比べて63%の削減、ほぼ1/3になっています。
しかも、論理コア数と同じ8にすることで、なんとさらに36%削減でき、元のコードのちょうど1/4となりました。ハイパースレッディングの効果はあまり期待していなかったのですが、このようなコードの場合は、とても大きな効果があるようですね。
ちなみに、それを超える16にした場合は、8とほぼ同じ(むしろ若干悪い)という、予想に難くない結果になりました。
ちなみに、AMD Ryzen 5-3600(6物理コア、3600MHz)のPCで12ゴルーチンで実行すると、平均で22msで計算が終わりました。これなら、余裕をもって30fpsのリアルタイム動画も作れますね。マンデルブロー集合上の面白いポイントにズームしながら、色も変えていくような効果を作りこめば、ちょっと面白いものになりそうです。
Ubuntuでの動作
Fyneはプラットフォーム非依存のGUIパッケージなので、最後にUbuntu 20.04上でも動作させてみました。
Fyneのインストールやアプリのビルドもスムーズにいき、下のように実行できました。
現時点では、wxGoよりマルチプラットフォームアプリの作成はしやすそうに感じました。また、FyneはAndoroidやiOSで動作するアプリのクロス開発もできるようなので、いろいろと遊べそうでもあります。
――――――――――――――――――――――――――――――――――
マンデルブロー集合の計算コードなんて、大学生のころにコードを書いて以来だったのですが、懐かしさもあってなかなか楽しかったです。せっかくなので次回に記事でも、このネタでもう少し遊んでみたいと思いますw
最後に最終コードを載せておきますので、ご参考にしていただければ幸いです。
package main
import (
"fmt"
"image"
"image/color"
"math/cmplx"
"strconv"
"time"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
)
func Calc(img *image.RGBA, rmin float64, rmax float64, imin float64, imax float64) {
for y := 0; y < 800; y++ {
for x := 0; x < 800; x++ {
var C = complex(rmin+(rmax-rmin)*float64(x)/800, imin+(imax-imin)*float64(y)/800)
var V = C
var it uint8 = 0
for ; it < 100; it++ {
V = V*V + C
if cmplx.Abs(V) > 2 {
break
}
}
img.Set(x, y, color.RGBA{uint8(cmplx.Abs(V) * 40), uint8(cmplx.Abs(V) * 120), it * 2, 255})
// for black and white
/*
if it < 100 {
img.Set(x, y, color.White)
} else {
img.Set(x, y, color.Black)
}
*/
}
}
}
func CalcGR(img *image.RGBA, rmin float64, rmax float64, imin float64, imax float64, offset int, nGR int, fchannel chan int) {
for y := 0; y < 800; y++ {
for x := offset; x < 800; x += nGR {
var C = complex(rmin+(rmax-rmin)*float64(x)/800, imin+(imax-imin)*float64(y)/800)
var V = C
var it uint8 = 0
for ; it <= 100; it++ {
V = V*V + C
if cmplx.Abs(V) > 2 {
break
}
}
img.Set(x, y, color.RGBA{uint8(cmplx.Abs(V) * 40), uint8(cmplx.Abs(V) * 120), it * 2, 255})
}
}
fchannel <- 1
}
func main() {
myApp := app.New()
win := myApp.NewWindow("Mandelbrot")
img := image.NewRGBA(image.Rect(0, 0, 800, 800))
canvasImg := canvas.NewImageFromImage(img)
canvasImg.FillMode = canvas.ImageFillOriginal
rangeRealMin := widget.NewEntry()
rangeRealMin.Text = "-1.7"
rangeRealMax := widget.NewEntry()
rangeRealMax.Text = "0.7"
rangeImaginaryMin := widget.NewEntry()
rangeImaginaryMin.Text = "-1.2"
rangeImaginaryMax := widget.NewEntry()
rangeImaginaryMax.Text = "1.2"
numGoRoutines := widget.NewEntry()
numGoRoutines.Text = "4"
statusLabel := widget.NewLabel("")
win.SetContent(container.NewVBox(
canvasImg,
container.NewHBox(
widget.NewLabel("Range: Real"),
rangeRealMin,
widget.NewLabel("~"),
rangeRealMax,
widget.NewLabel("Imaginary"),
rangeImaginaryMin,
widget.NewLabel("~"),
rangeImaginaryMax,
layout.NewSpacer(),
widget.NewButton("Start", func() {
realMin, _ := strconv.ParseFloat(rangeRealMin.Text, 64)
realMax, _ := strconv.ParseFloat(rangeRealMax.Text, 64)
imaginaryMin, _ := strconv.ParseFloat(rangeImaginaryMin.Text, 64)
imaginaryMax, _ := strconv.ParseFloat(rangeImaginaryMax.Text, 64)
startTime := time.Now()
//Calc(img, realMin, realMax, imaginaryMin, imaginaryMax)
nGR, _ := strconv.ParseInt(numGoRoutines.Text, 10, 32)
fchannel := make(chan int)
defer close(fchannel)
for i := 0; i < int(nGR); i++ {
go CalcGR(img, realMin, realMax, imaginaryMin, imaginaryMax, i, int(nGR), fchannel)
}
for i := 0; i < int(nGR); i++ {
<-fchannel
}
timeDiff := time.Now().Sub(startTime)
statusLabel.Text = fmt.Sprintf("Calculation Time: %d msec", timeDiff.Milliseconds())
canvasImg.Refresh()
}),
),
container.NewHBox(
widget.NewLabel("Number of Go Routines"),
numGoRoutines,
layout.NewSpacer(),
statusLabel,
),
))
win.ShowAndRun()
}
――――――――――――――――――――――――――――――――――
《関連マガジン》
SHIFTエンジニアが学びながら解説するGo言語
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/