土田 拓也

テクノロジやビジネス、デザイン、ライフスタイルについて思惟するシステムエンジニア

  • About
  • Blog
    • Technology
    • Business
    • Design
    • Lifestyle
  • Contact
  • Github
  • LinkedIn
  • RSS
  • Twitter
© 2025 Takuya Tsuchida

Rust と WebAssembly でマンデルブロ集合を描画する

2022年2月9日

『プログラミング Rust 第2版』と The Rust Wasm Book を読んでいたら手を動かしたくなったので、Rust で書いたコードを WebAssembly にコンパイルしてマンデルブロ集合を描画してみました。開発環境構築から実装までを説明しています。

Rust と WebAssembly の開発環境を構築する

Rust で書いたコードを WebAssembly にコンパイルするためには、パッケージマネージャーの Cargo など一般的な Rust の開発ツールだけではなく、wasm-pack や Node.js などが必要になります。このあたりは Rust and WebAssembly – Setup を参考にしています。

rustup をインストールする

rustup は Rust のインストーラーです。下記のコマンドでインストールします。rustup をインストールすることで一般的な Rust の開発ツール一式がインストールされます。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

rustup で Rust のアップデートもできます。Homebrew などのパッケージマネージャーで Rust をインストールすることもできますが、Visual Studio Code で Rust をサポートする拡張機能がデフォルトのままだと動作しないので、現時点では rustup を使用するのが無難です。

wasm-pack をインストールする

wasm-pack は Rust と WebAssembly の開発を支援するツールです。下記のコマンドでインストールします。Rust をインストールした直後で cargo が見つからない場合は、ターミナルを再起動してください。

cargo install wasm-pack

Node.js をインストールする

Node.js をインストールします。Rust と WebAssembly の開発で npm を使用するためです。Homebrew を使用していれば、下記のコマンドでインストールできます。

brew install node

Rust と WebAssembly の新規プロジェクトを作成する

Hybrid Applications with Webpack – Hello wasm-pack! を参考に、npm で Rust と WebAssembly の新規プロジェクトを作成します。プロジェクト名は mandelbrot-wasm とします。

npm init rust-webpack mandelbrot-wasm

node_modules ディレクトリを除外したプロジェクト構造は下記のようになっています。

mandelbrot-wasm/
|-- .gitignore
|-- Cargo.toml
|-- README.md
|-- js/
|   `-- index.js
|-- package-lock.json
|-- package.json
|-- src/
|   `-- lib.rs
|-- static/
|   `-- index.html
|-- tests/
|   `-- app.rs
`-- webpack.config.js

プロジェクトディレクトリに移動し、npm でプロジェクトをビルドします。

cd mandelbrot-wasm
npm run build

npm で開発用 Web サーバーを起動します。

npm start

ブラウザーから http://localhost:8080 にアクセスすると、コンソールに Hello world! と表示されます。

これで開発する準備が整いました。

マンデルブロ集合を描画するコードを実装する

それではマンデルブロ集合を描画するコードを実装していきましょう。

描画先となるキャンバス要素を追加する

マンデルブロ集合の描画先となるキャンバス要素を static/index.html の8行目に追加します。今回は幅と高さも指定してしまいます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>My Rust + Webpack project!</title>
  </head>
  <body>
    <canvas id="canvas" width="640" height="360"></canvas>
    <script src="index.js"></script>
  </body>
</html>

マンデルブロ集合の計算処理を追加する

『プログラミング Rust 第2版』で紹介されているマンデルブロ集合を描画するコードを ProgrammingRust/mandelbrot リポジトリの src/main.rs からコピーし、src/lib.rs の末尾に貼り付けます。

use num::Complex;

fn escape_time(c: Complex<f64>, limit: usize) -> Option<usize> {
    let mut z = Complex { re: 0.0, im: 0.0 };
    for i in 0..limit {
        if z.norm_sqr() > 4.0 {
            return Some(i);
        }
        z = z * z + c;
    }

    None
}

fn pixel_to_point(bounds: (usize, usize),
                  pixel: (usize, usize),
                  upper_left: Complex<f64>,
                  lower_right: Complex<f64>)
    -> Complex<f64>
{
    let (width, height) = (lower_right.re - upper_left.re,
                           upper_left.im - lower_right.im);
    Complex {
        re: upper_left.re + pixel.0 as f64 * width  / bounds.0 as f64,
        im: upper_left.im - pixel.1 as f64 * height / bounds.1 as f64
        // Why subtraction here? pixel.1 increases as we go down,
        // but the imaginary component increases as we go up.
    }
}

fn render(pixels: &mut [u8],
          bounds: (usize, usize),
          upper_left: Complex<f64>,
          lower_right: Complex<f64>)
{
    assert!(pixels.len() == bounds.0 * bounds.1);

    for row in 0..bounds.1 {
        for column in 0..bounds.0 {
            let point = pixel_to_point(bounds, (column, row),
                                       upper_left, lower_right);
            pixels[row * bounds.0 + column] =
                match escape_time(point, 255) {
                    None => 0,
                    Some(count) => 255 - count as u8
                };
        }
    }
}

複素数計算に使用している num クレートへの依存を Cargo.toml の24行目に追加します。

[dependencies]
num = "0.4"

# The `wasm-bindgen` crate provides the bare minimum functionality needed

マンデルブロ集合の計算結果をキャンバス要素に描画する

マンデルブロ集合の計算処理と描画先のキャンバス要素が揃いました。web-sys: canvas Julia set – The `wasm-bindgen` Guide を参考に、それらを連携させるコードを書いていきます。

まず、キャンバス要素から 2D レンダリングコンテキストを取得し、そのコンテキストと幅、高さを引数に draw 関数を呼び出すように js/index.js を修正します。

import("../pkg/index.js").then(wasm => {
    const canvas = document.getElementById("canvas");
    const context = canvas.getContext("2d");
    wasm.draw(context, canvas.width, canvas.height);
}).catch(console.error);

つぎに、マンデルブロ集合の計算処理を使用してキャンバス要素に描画する draw 関数を src/lib.rs の末尾に追加します。

use wasm_bindgen::Clamped;
use web_sys::CanvasRenderingContext2d;
use web_sys::ImageData;

#[wasm_bindgen]
pub fn draw(
    context: &CanvasRenderingContext2d,
    width: usize,
    height: usize,
) -> Result<(), JsValue> {
    let mut pixels = vec![0; width * height];
    render(
        &mut pixels,
        (width, height),
        Complex { re: -2.2777, im: 1.0 },
        Complex { re: 1.2777, im: -1.0 },
    );
    let data: Vec<u8> = pixels
        .iter()
        .flat_map(|&pixel| vec![pixel, pixel, pixel, 255])
        .collect();
    let image_data = ImageData::new_with_u8_clamped_array(
        Clamped(&data),
        width as u32,
    )?;
    context.put_image_data(&image_data, 0.0, 0.0)
}

render 関数の結果を、キャンバスに反映させるまでの変換が特徴的なので簡単に解説します。

96〜99行目では、1ピクセルを1つの8ビット値で表現したグレースケール画像データから、1ピクセルを4つの8ビット値で表現した RGBA カラー画像データに変換しています。4つの8ビット値は赤・緑・青・アルファチャンネルの順で並んでいます。

100〜103行目では、Vec<u8> から Clamped<&[u8]>(JavaScript の Uint8ClampedArray 相当)に変換し、そこから ImageData を生成しています。そして、104行目では、コンテキストを使用して ImageData をキャンバスに描画しています。Canvas API を Rust から使用しているだけなので、詳細は Canvas API – Web API | MDN を確認してください。

最後に、draw 関数を動作させるために、web-sys クレートの機能 CanvasRenderingContext2d と ImageData を Cargo.toml に追加します。

# The `web-sys` crate allows you to interact with the various browser APIs,
# like the DOM.
[dependencies.web-sys]
version = "0.3.22"
features = [
    "console",
    "CanvasRenderingContext2d",
    "ImageData",
]

npm start を実行している状態で、ブラウザーから http://localhost:8080 にアクセスすると、マンデルブロ集合が描画されます。

マンデルブロ集合の表現を改善する

描画したマンデルブロ集合は特徴的な見た目であるものの表現が淡白なので、マンデルブロ集合周辺部の表現を改善してみます。

マンデルブロ集合周辺部の輝度を反転する

マンデルブロ集合の周辺から離れると明るくなる表現を反転して、マンデルブロ集合の周辺から離れると暗くなる表現にしてみます。

src/lib.rs の73行目、render 関数で escape_time 関数が返す「z が半径2の円を脱出するまでの計算回数」を255から引いています。この255から引く処理を削除して、計算回数をそのまま使用するように修正します。

            pixels[row * bounds.0 + column] =
                match escape_time(point, 255) {
                    None => 0,
                    Some(count) => count as u8
                };

npm start を実行している状態で、ブラウザーから http://localhost:8080 にアクセスすると、マンデルブロ集合周辺部の輝度が反転して描画されます。

マンデルブロ集合周辺部の輝度をガンマ補正する

マンデルブロ集合周辺部が暗すぎるので γ = 1 / 2.2 でガンマ補正してみます。

src/lib.rs の98行目、draw 関数で pixels を変換しているところにガンマ補正を適用するコードを追加します。また、99行目を型が合うように修正します。

    let data: Vec<u8> = pixels
        .iter()
        .map(|&pixel| ((pixel as f64 / 255.0).powf(1.0 / 2.2) * 255.0) as u8)
        .flat_map(|pixel| vec![pixel, pixel, pixel, 255])
        .collect();

npm start を実行している状態で、ブラウザーから http://localhost:8080 にアクセスすると、マンデルブロ集合周辺部がガンマ補正された輝度で描画されます。マンデルブロ集合周辺部にあるオーラのような領域が視覚的にわかりやすくなりました。

マンデルブロ集合周辺部を着色する

マンデルブロ集合周辺部を赤で着色してみます。

src/lib.rs の99行目、draw 関数でカラー画像データに変換しているところで赤の輝度のみを使用するように修正します。

    let data: Vec<u8> = pixels
        .iter()
        .map(|&pixel| ((pixel as f64 / 255.0).powf(1.0 / 2.2) * 255.0) as u8)
        .flat_map(|pixel| vec![pixel, 0, 0, 255])
        .collect();

npm start を実行している状態で、ブラウザーから http://localhost:8080 にアクセスすると、マンデルブロ集合周辺部が赤で着色されて描画されます。最初のマンデルブロ集合よりは美しく描画できているのではないでしょうか。

マンデルブロ集合の着色アルゴリズムは色々あるようなので興味がある人は調べてみてください。


本稿では、Rust で書いたコードを WebAssembly にコンパイルしてマンデルブロ集合を描画する方法を説明しました。Rust から WebAssembly へのコンパイルを試してみたい人の参考になれば幸いです。

参考文献

プログラミング Rust 第2版
The Rust Wasm Book
The wasm-bindgen Guide
The wasm-pack Book
Canvas API – Web API | MDN

カテゴリ:テクノロジ タグ:Rust, WebAssembly