気を散らすノート

色々と散った気をまとめておくところ.論文読んだり自分で遊んだりする.たぶん.

Rust + nannou でお絵かき part1: Cellular automata

自分 advent calendar 2019, 14日目の記事です.

f:id:lesguillemets:20191214235148p:plain
いつものアレ

nannou は Rust でのお絵かきライブラリ a creative-coding framework です.ざっくりいえば processing をイメージすれば良さそう. Rust なので (i) 書きやすくて (ii) ちゃんと速い のが期待できそう. ちょっと触ってみようかな…ということで,晩の23時頃まで開催されたアンケート結果をもとに

f:id:lesguillemets:20191215122949p:plain
ご協力ありがとうございました
ふつうの2次元 cellular automata (Conway's game of life) を作ってみます.

公式ページはかっこいいんだけど今のところは guideチュートリアルが未完成,なので, 冒頭流し見てあとはリポジトリexamples なんかを見ながらとりあえず触る感じで.

最終的なコードは https://github.com/lesguillemets/nnlz にあります.

nannou app の基本構成

チュートリアル にこの辺は書いてある.

基本的には,

  • 今描画してる世界の状態の表し方
    • (惑星シミュレーションなら各惑星の位置と速度とか,ライフゲームなら各 cell の生き死にとか)
  • それを,各ステップでどう更新するか
  • 各ステップでそれをどう描画するか
  • 必要なら,マウスとかキーボードからの入力に応じて何をするか

…が揃えば,あとは初期状態を渡せば世界が進んでいく,ということになる.

これを踏まえてコードを書くことになる. 基本構造はわかりやすくて,上記を素直に落とし込む形だ

use nannou::prelude::*;

// 状態をもっとく構造
struct Model {}

fn main() {
    nannou::app(model)          // app 作る
        .event(event)           // イベントハンドラ
        .simple_window(view)    // view
        .run();
}

// &App: ウィンドウとかそういう情報をもってる,
// から初期状態を作る
fn model(_app: &App) -> Model { Model {} }

// マウスの動きや,フレームの進みとかそういうイベントに応じて
// 状態の更新
fn event(_app: &App, _model: &mut Model, _event: Event) { }

// 状態を各フレームでどう描画するか
fn view(_app: &App, _model: &Model, _frame: &Frame) { }

内部的には nannou::appBuilder を返すので,それに生えてるメソッドでハンドラとかを設定する形になります.

ということでこれをもとに書いていきましょう.

下準備

普通に

$ cargo new --bin cellular_automata_nannou

Cargo.toml に依存を書き加えて

[dependencies]
nannou = "0.12.0"

で準備は完了.

まずはふつうのセル・オートマトンの方を用意しておきます.以下 src/lib.rs

まず Cell で各マスを表すことにします.生存してるかどうか教えてくれる is_alive() と ご近所さんを数えるときに使うかもしれない as_num() を生やしておきましょう( c.is_alive().into() でいいんだけどね)

use nannou::rand::prelude::*;

#[derive(Copy, Debug, Clone)]
pub enum Cell {
    Dead,
    Alive,
}

impl Cell {
    pub fn as_num(self) -> u8 {
        match self {
            Cell::Dead => 0,
            Cell::Alive => 1,
        }
    }
    pub fn is_alive(self) -> bool {
        match self {
            Cell::Dead => false,
            Cell::Alive => true,
        }
    }
}

Model == 世界の状態を保持しておく構造体,には,世界の縦幅横幅と,Cell の一次元 vector の形で現在の情報を もっておきます.それから指定された縦横でランダムにCellをばらまくメソッドを生やしておきます

#[derive(Clone)]
pub struct Model {
    pub world: Vec<Cell>,
    pub width: u32,
    pub height: u32,
}

impl Model {
    pub fn random(width: u32, height: u32) -> Self {
        // こうやって使いまわしたほうが効率がよいらしい
        let mut rng = rand::thread_rng();
        let world = (0..width * height)
            .map(|_| {
                if rng.gen::<bool>() {
                    Cell::Alive
                } else {
                    Cell::Dead
                }
            })
            .collect();
        Model {
            world,
            width,
            height,
        }
    }
}

そして次世代の計算.眠くて急いでるときは効率が悪い頭の悪い方法でも off-by-one とかを起こしにくい方法でやるのが良い. 端っこはつながるようにしておきます. x.checked_sub(1).unwrap_or(self.width-1) では例えば,負の座標になったときに 右端に戻すというのをしている.

impl Model {
    fn at(&self, x: u32, y: u32) -> Cell {
        self.world[(y * self.width + x) as usize]
    }
    pub fn neighbours_of(&self, loc: u32) -> u8 {
        // TODO: 累積和で書き直す
        let mut ns = 0;

        // 自分を含めた 3x3 マスのなかの生存者を数える
        let (x, y) = (loc % self.width, loc / self.width);
        for &neighbour_x in &[
            x.checked_sub(1).unwrap_or(self.width - 1),
            x,
            (x + 1) % self.width,
        ] {
            for &neighbour_y in &[
                y.checked_sub(1).unwrap_or(self.height - 1),
                y,
                (y + 1) % self.height,
            ] {
                ns += self.at(neighbour_x, neighbour_y).as_num();
            }
        }

        // 自分のカウントを引けばご近所さんの数に
        ns -= self.world[loc as usize].as_num();
        ns
    }
}

それから,ルールを指定できる構造も一応作っておきましょう.

pub struct Rule {
    /// Cell::Dead  の場所は birth_min <= neighbour <= birth_max で生誕
    /// Cell::Alive の場所は alive_min <= neighbour <= alive_max で死滅
    pub birth_min: u8,
    pub birth_max: u8,
    pub alive_min: u8,
    pub alive_max: u8,
}

ここまでで下準備.

お絵かき

まずいくつか定数を定義して,main を書いておきます

use nannou::prelude::*;
use cellular_automata_nannou::*;

const CELLSIZE: u32 = 2;               // 1マス何pxで描くか
const WIDTH_IN_CELLS: u32 = 300;       // 横幅,何マスか
const HEIGHT_IN_CELLS: u32 = 300;      // 縦幅,何マスか
const WIDTH_IN_PIX: u32 = WIDTH_IN_CELLS * CELLSIZE;
const HEIGHT_IN_PIX: u32 = HEIGHT_IN_CELLS * CELLSIZE;

const COLOUR_ALIVE: Rgb<u8> = LIMEGREEN;
const COLOUR_DEAD: Rgb<u8> = DARKOLIVEGREEN;

const RULE: Rule = Rule {
    birth_min: 3,
    birth_max: 3,
    alive_min: 2,
    alive_max: 3,
};


fn main() {
    nannou::app(|app: &App| {
        // window の大きさを設定したいんだが…
        app.window(app.window_id())
            .unwrap()
            .set_inner_size_pixels(WIDTH_IN_PIX as u32, HEIGHT_IN_PIX as u32);
        Model::random(WIDTH_IN_CELLS, HEIGHT_IN_CELLS)
    })
    .update(update)
    .simple_window(view)
    .run();
}

ウィンドウの大きさをどこで設定したらいいのかが微妙にわからない.とりあえず,App::window(&self, id:ID) でとれる WindowWindow::set_inner_size_pixel(&self, width: u32, height: u32) が生えているのでこれを使った. &mut self じゃなくていいのが気持ち悪いけど,表示を変えるメッセージを送るだけで struct としての中身は変わらないのでいいのだろう1

あとは updateview を埋めるだけ.前者から見てみましょう

fn update(_app: &App, model: &mut Model, _update: Update) {
    // 普通のオートマトン
    let current = model.clone();
    for (i, &cell) in current.world.iter().enumerate() {
        let neighbours = current.neighbours_of(i as u32);
        if cell.is_alive() {
            if neighbours < RULE.alive_min || RULE.alive_max < neighbours {
                model.world[i] = Cell::Dead;
            }
        } else {
            // for dead cells
            if RULE.birth_min <= neighbours && neighbours <= RULE.birth_max {
                model.world[i] = Cell::Alive;
            }
        }
    }
}

_update には tick に関する情報とかが入ってるはずだが今は必要ない.とにかくひとコマ勧めるごとに model.world を1世代進めればよいのです.

view については nannou::draw が対応している. 大事なのはDrawing. 基本的には App::draw()Draw を取ってきて, そこに生えてる rect とかで上の Drawing を作る. Drawing::width, Drawing::radians, Drawing::color のように (Self -> setting -> Self の形の)メソッドが生えてるので, これらを使って設定をしていくという仕掛けだ.

fn view(app: &App, model: &Model, frame: &Frame) {
    // Begin drawing
    let draw = app.draw();

    // 死亡のマスの色で埋めて,生きてるマスだけ描画する
    draw.background().color(COLOUR_DEAD);

    for (i, _cell) in model.world.iter().enumerate().filter(|(_, c)| c.is_alive()) {
        draw.rect()
            .x_y(
                // (i) 座標系として (0,0) が画面の中央にある
                // (ii) rect().x_y() も中心からの設定になる-のでこういうのが必要
                // 多分設定できるんだけど見つからない
                ((CELLSIZE * (i as u32 % WIDTH_IN_CELLS) + CELLSIZE / 2) as i64
                    - WIDTH_IN_PIX as i64 / 2) as f32,
                ((CELLSIZE * (i as u32 / WIDTH_IN_CELLS) + CELLSIZE / 2) as i64
                    - HEIGHT_IN_PIX as i64 / 2) as f32,
            )
            .width(CELLSIZE as f32)
            .height(CELLSIZE as f32)
            .color(COLOUR_ALIVE);
    }

    // Write the result of our drawing to the window's frame.
    draw.to_frame(app, &frame).unwrap();
}

問題は座標が全部中心を0としてるっぽいことで,上ではとりあえず手動で offset を計算したのだが,多分これ設定できるはずなんですよね…

ともかく とりあえずこれで cargo run --release, 結果はキビキビ動く.

(Kazam で綺麗に取れた screencast を上げてみたら結構気持ち悪い感じになった.いかにもmp4の苦手な動画ですねえ…)

まとめ

  • 素直な構成で書きやすい
  • わりとちゃんと速度出せるんだと思う
  • もうちょっど docs 読み込めば若干シンプルにはできそう

  1. ここに mut が必要になると,多分マウスで大きさを直接変えたときとかに整合性が取れない