Rustでワールドマップ生成(パーリンノイズ)

概要

プログラミング言語Rustとライブラリ(image + noise)を用いてRPGのワールドマップのようなものを生成した。

このポストではimageとnoiseを使って、以下の画像を作る方法を雑に述べる。マップ生成に本質的な役割を果たすパーリンノイズはライブラリに頼っているため、その部分についての説明はきちんとできないことを許していただきたい。

今回書いたコードはgistに上げたひどいコードなのでそのままだと使えないと思うが、許してくれ。

noiseの使い方

noise-rsはパーリンノイズなど各種ノイズ関数を取り揃えているRustのライブラリである。

パーリンノイズは勾配ノイズ(gradient noise)の一種で、値がなめらかに変化するランダムな関数である。ゲームコンテンツ生成の業界ではよく使われるものらしい。理屈についてはパーリンノイズを理解する | プログラミング | POSTD で詳しく述べられている。

noiseライブラリのPerlinの使い方を見ていく。といっても単にnewして値が欲しい座標にgetをかけるだけなので難しいことは何もない。

let perlin = Perlin::new();
perlin.set_seed(0); // seedを固定して同じ結果を再現する
perlin.get([0.1, 0.2]); // 2次元上のノイズを得る

imageの使い方

imageはRustの画像処理ライブラリである。

imageを使って画像を生成するにはImageBuffer::newImageBuffer::from_fnを使う。ここではノイズ関数に沿って画像を生成したいので、ImageBuffer::from_fnを利用する。

from_fnの型はfn from_fn<F>(width: u32, height: u32, f: F) -> ImageBuffer<P, Vec<P::Subpixel>> where F: Fn(u32, u32) -> Pである。

大仰に見えるが、生成する画像のタテ・ヨコの大きさとクロージャを受け取るだけの関数である。クロージャは画像のある座標点を受け取り、返り値はそのピクセルが何色になるかを計算してやればよい。

試しに(パーリン)ホワイトノイズを生成するプログラムを書いてみよう。

let width = 800;
let height = 640;

let white_noise = |x: u32, y: u32| {
    let altitude = perlin.get([x as f32 / 160, y as f32 * 108]) + 1.0; // altitude: 標高
    let altitude = (altitude * 128.0) as u8;
    image::Luma([altitude])
};

let image_buf = ImageBuffer::from_fn(width, height, island);

perlin.get()の値は-1.0 ~ +1.0の間で変動する。このため結果に1.0を足している。これで値は0.0~2.0の間に収まるはずである。これに128.0を掛けて値を0.0~256の範囲としている(u8なので256だとオーバーフローする。perlin.get()の値が1.0以下なのか未満なのかで違うはずだが、よく分かっていない)。

perlin.get()内の160とか108とかいう値は周期を表している。これはwidthheightの1/5の値である。こういう値を式にベタ書きするべきではないのだが、うまく名前をつけられないのでベタに書いている。あとで書き直したい(消え入る声)。

Lumaというのはグレイスケールのピクセルを表現するデータ型である。白黒画像を作りたいときに簡便だ。

バッファの画像をファイルに書き出してやる。これはドキュメントのサンプルプログラムそのままである。

let ref mut fout = File::create(&Path::new("output.png")).unwrap();
let _ = image::ImageLuma8(image).save(fout, image::PNG).unwrap();

自然なノイズを作る

何が自然なのかという話はさておき…

ホワイトノイズの例を見てなんとなくわかるように、単にPerlinを呼び出すだけでは、最初に提示した画像を作ることはできない。地形の画像を作るには、少し地形の性質について考えをめぐらす必要がある。

地形というのはどうやってできているのか。それは、長いスパンで起きる大きな地形の変化(山や海)の上に、短いスパンで起こる小さな変化(小さい山、谷)が乗っていて、その上にさらに短い周期で起こるより小さな変化(丘や岩など)があり…というふうになっている(とここでは考える)。

これをノイズ関数の言葉で表すと、長い周期の振幅の大きい関数(大地形)の上に、短い周期の振幅の小さな関数(小地形)がいくつも乗っている(=足されている)という風に考える。*1

では、単にPerlinを一度呼び出して作った白黒画像には何が足りなかったのか。画像を見て分かることは、ノイズの変化が大味で細かい地形を表現できていないということである。周期が短く振幅の小さいノイズを足してやる必要がある。

ホワイトノイズを作るコードを次のように書き換える。

let white_noise = |x: u32, y: u32| {
    let altitude = perlin.get([x as f32 / 160.0, y as f32 / 108.0]) + 1.0
                 + 0.5 * (perlin.get([x as f32 / 40.0, y as f32 / 32.0]) + 1.0)
                 + 0.25 * (perlin.get([x as f32 / 20.0, y as f32 / 16.0]) + 1.0);
    let altitude = (altitude * 75.0) as u8;
    image::Luma([altitude])
};

マジックナンバーの嵐で申し訳ないが、何をしているかは読み取っていただけると思う。ノイズの振幅を1.0, 0.5, 0.25と減らしながら、周期を(width及びheightの)1/5, 1/15, 1/30)と短くしている。これで地形のような模様ができるはずだ。

結果は次のようになる。

最初のマップ画像と交互に見比べてみてほしい。実は色がついていないだけで、ノイズの出自は同じ画像であることがわかる。

色をつける

最後に、作成したノイズ画像に色を付ける。これは創作の域であるが、適当にやっつけてしまおう。

    let island = |x: u32, y: u32| {
        let altitude = 1.0 * (perlin.get([x as f32 / 160.0, y as f32 / 108.0]) + 1.0)
                     + 0.5 * (perlin.get([x as f32 / 40.0, y as f32 / 32.0]) + 1.0)
                     + 0.25 * (perlin.get([x as f32 / 20.0, y as f32 / 16.0]) + 1.0);
        let altitude = (altitude * 75.0) as u8;
        match altitude {
            0  ...63  => image::Rgba([  0,   0, 228, 255]), // 深海
            64 ...127 => image::Rgba([  0,   0, 255, 255]), // 海原
            128...143 => image::Rgba([128, 128, 255, 255]), // 浅瀬
            144...151 => image::Rgba([239, 221, 111, 255]), // 砂場
            152...167 => image::Rgba([180, 255, 170, 255]), // 草原
            168...184 => image::Rgba([100, 255, 100, 255]), // 深緑
            185...207 => image::Rgba([189, 200, 100, 255]), // 山地
            208...255 => image::Rgba([255, 255, 255, 255]), // 雪山
            // 以下を書かないと「網羅的でない(non-exhaustive)」とコンパイラに怒られる。
            // u8の取る範囲は上で網羅していると思うのだが…勘違いだろうか…よくわからない…
            _         => image::Rgba([  0,   0,   0,   0]), 
        }
    };

クロージャを新たにislandとして定義している。返り値のピクセルの型はLumaからRgbaに変わっている。

ImageBuffer::from_fnが使うクロージャを変更し、画像の書き出しをLumaからRgba向けに書き換える。

    let image_buf = ImageBuffer::from_fn(width, height, island);
    let ref mut fout = File::create(&Path::new("island.png")).unwrap();
    let _ = image::ImageRgba8(image_buf).save(fout, image::PNG).unwrap();

これを実行すると最初のワールドマップ画像が出来るはずである。もしこの記事を最後まで読んでくれた奇特な方、うまく行ったらおめでとう。おわり!

次やること

マップの出来栄えを上げる

古典的なRPGでは地図の端には島がない。今回の地図はただのノイズなので、適当なサイズで作るとどうしても上端と下端、右端と左端が繋がらなくなる。

こうした不整合は困るので、端は海としたほうが簡単である。そのような地図を作るにはどうしたらよいか。

一つの方法は、画像の端に近づく(=中心から離れる)につれて減少する距離関数でマスクしてやることである。このような距離関数の作り方はいくつかあるだろう(円や四角など)。これは簡単な課題である。

参考リンク: 多分たくさんあるし、そもそも簡単に思いつくが、ググって引っかかったのはこれ

アルゴリズムを変える

次のリンクはProcedural Generation関係では有名なサイトである。ボロノイ図を用いて島を生成している。生成している地図の品質が高く、気持ち惹かれるのだが、英語だし長いので私は今のところ読む元気がない。

Polygonal Map Generation for Games

余談

range記法(0..63のような書き方)でマッチしようとするとコンパイラに怒られる。

error: exclusive range pattern syntax is experimental (see issue #37854)
  --> src\main.rs:52:13
   |
52 |             0  ..63  => image::Rgba([  0,   0, 228, 255]),
   |             ^^^^^^^

inclusive rangeを使えということらしい(0...630 <= x <= 63の整数、0..630 <= x < 63の整数である)。

*1:この説明は上にリンクしたPOSTDの解説の言い回しを冗長に言い直しただけです