はじめに
三年前に以下の記事を書いた。内容はタイトルそのまんま。
Rust でマンデルブロ集合のズーム・アニメーション - Qiita
今回の記事は,そのコードをちょこっと修正しただけ。元記事を修正しようかと思ったが,ややこしいので新たな記事を起こした。
成果物
こんな APNG アニメーションができました。
(2020-07-20 追記)これ,APNG 画像を貼り付けのにアニメーションとして表示されません。下の小節の追記参照。
新しい記事を書いたきっかけ
きっかけは,久々に件の記事を見たところ,記事に貼り込んだ APNG アニメーション画像が動かなかったこと。どうやらいつの間にか APNG がただの PNG になっていたっぽい。
原因は分からないが,Qiita さんが PNG を最適化でもして,その際に APNG の最初のフレーム以外が消えてしまったのかも?と想像している。濡れ衣だったらごめんなさい。
(投稿直後の追記)
上の画像はやっぱり静止画として表示される。うーむ。投稿後最初の閲覧ではちゃんと動画になってたのになあ。
調べたら WebP(静止画像フォーマットの一つ)に変換されていた。なんでやねん!
とりあえず以下の URL にアクセスしてもらうと,ちゃんと APNG 動画として表示されるぽい。でもこの URL が恒久的に生きるのかどうかはよく分からない。
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48101/10b24932-5761-5bda-7b79-0a305c15654b.png
変更点
大きな変更点としては,元記事で「よく分かんなかった」と書いた並列化に再挑戦し,実現したこと。
小さな点としては,画像サイズを一箇所で定義し,そこだけ変えればよいようにしたことなど。
ちなみに,当初 800×800 px で 200 コマの APNG を作ったらファイルサイズが大きくて Qiita に投稿できなかったので,以下のコードでは 400×400 px にしている。
コード
image = "0.23.7"
num = "0.3.0"
rayon = "1.3.1"
use num::complex::Complex;
use num::Float;
use rayon::prelude::*;
use std::f64::consts::PI;
fn n2color(n: usize) -> image::Rgb<u8> {
const A0: [f64; 3] = [255.0, 32.0, 64.0];
const A1: [f64; 3] = [210.0, 10.0, 30.0];
const B0: [f64; 3] = [255.0, 255.0, 255.0];
const B1: [f64; 3] = [ 60.0, 20.0, 255.0];
let v = (n as f64 * 0.02).atan() / PI * 2.0;
let v_1 = 1.0 - v;
let (a, b) = if n % 2 == 0 { (A0, B0) } else { (A1, B1) };
image::Rgb([
(v_1 * a[0] + v * b[0]) as u8,
(v_1 * a[1] + v * b[1]) as u8,
(v_1 * a[2] + v * b[2]) as u8,
])
}
fn m(c: Complex<f64>, iter_max: usize) -> usize {
let mut z: Complex<f64> = Complex::new(0.0, 0.0);
for i in 0..iter_max {
z = z * z + c;
if z.norm_sqr() > 4.0 {
return i;
}
}
iter_max
}
fn write_image(
size: u32,
center: Complex<f64>,
iter_max: usize,
palette: &Vec<image::Rgb<u8>>,
span: f64,
filename: &str,
) {
let black = image::Rgb([0, 0, 0]);
let mut imgbuf = image::ImageBuffer::new(size, size);
let unit: f64 = span / size as f64;
for (x, y, pixel) in imgbuf.enumerate_pixels_mut() {
let c = center
+ Complex::new(
(x as f64 - (size as f64 - 1.0) * 0.5) * unit,
((size as f64 + 1.0) * 0.5 - y as f64) * unit,
);
let n = m(c, iter_max);
*pixel = if n == iter_max { black } else { palette[n] };
}
imgbuf.save(filename).unwrap();
}
fn main() {
const ITER_MAX: usize = 3600;
let center = Complex::new(-0.0938884202514072, 0.959822827920789);
let palette: Vec<image::Rgb<u8>> = (0..ITER_MAX).map(|i| n2color(i)).collect();
(0..200).into_par_iter().for_each(|i| {
let s = format!("png/foo-{:04}.png", i);
write_image(
400,
center,
ITER_MAX,
&palette,
4.0 * 0.87.powf(i as f64),
&s,
)
})
}
これだけ。
事前に png
というディレクトリーを作成しておく必要がある。
rayon による並列化
このプログラムは計 200 コマの PNG を書き出すのだが,各コマの計算と画像出力は完全に独立している。ならば並列に実行すれば実行時間が減るのではあるまいか。
貧弱なマシンで CPU のコアが二つしかないが,半分とは言わないまでも,たとえば 2/3 くらいの時間で終了すればうれしいかも。
これには rayon というクレートを使った。
冒頭で
use rayon::prelude::*;
としておき,画像の計算・出力部分を
(0..200).into_par_iter().for_each(|i| {
# なんとかかんとか
})
と書いたら動いた(ここに至るまでだいぶ試行錯誤した)。
画像が書き出されるとき,確かにファイル名の順にならなかったりするので,並列化できているぽい。
macOS の time
コマンドで時間を測ったところ,並列化した場合は,ただの for
でループしたときと比べて,CPU のユーザー時間は 1.5 倍くらいになったが,実時間は半分以下になった(そのときどきで時間は変動)。
え? 半分以下? コアが 2 個しかないので倍速以上にはなりえない気がするんだが???
なんかファイル I/O 関係の事情があるのかな? ←意味がよく分からずテキトーに言ってる
APNG 化
apngasm -o mandelbrot.png png/*.png -l 1 -d 1:20
とやって APNG 画像を作成している。
apngasm は macOS なら
brew install apngasm
でインストールできる。Windows とか Linux でも使える。
-o
オプションは出力ファイルの名前。-l 1
は 1 回だけアニメーション表示の意。もし -l 0
とすると,0 回じゃなくて無限に繰り返す。
-d
はフレームレート(というか各コマの表示時間)の指定なのだが,指定方法に二通りある。-d 1:20
のように書くと,各コマの表示時間が $\frac1{20}$ 秒になる。つまり,毎秒 20 コマだ。-d 2:3
だと $\frac23$ 秒。またコロンを使わずに -d 200
と書くと 200 ミリ秒の意になる。
感想
Ruby ばかり使っている私は,「速い計算」とか「並列処理」に憧れがある。
一方,何十年も前だが,不注意でメモリーを無茶苦茶にするプログラムを書いてしまって以来,「コンパイラこわいよコンパイラ」とトラウマになっている。
Rust は自分の足を撃つのがやりにくくなっているので,「もしや初心者にやさしい?」と思って遊んでみているが,Ruby と全然違う言語ということもあって興味深い。
並列化については,スーパー素人の私でも,「並列化に伴うオーバーヘッドがあるから CPU が 2 個あっても倍速にはならないんだぞ」ということは何となく分かっている。
なので,あんまり期待していなかったのだが,今回のコードについては面白いように高速化したので,ニンマリしている。