niba1122.dev

Rust+WebAssemblyでメタボールを作る

December 17, 2019

前回の記事の応用編ということで、Rust+WebAssemblyで3Dのメタボールを作ってみました。

メタボール

コードや動作サンプルは以下をご覧ください。

画面上でカーソルを左右に動かすと、2つのボールが離れたりくっついたりします。

描画方法

前回の記事では立方体だったので、立方体のポリゴンを定義して表示させました。しかし、メタボールでは円がくっついたり離れる度に形状が変化するため、レイトレーシングと呼ばれる方法で描画を行いました。レイトレーシング法は、立体空間の中に光線(レイ)を走らせて物体に衝突させ、その結果に基づいて3Dを描画する方法です。

GLSLを書く準備

https://wgld.org/d/glsl/g001.html に従い、レイトレーシング法で描画するためのポリゴンを定義し、更にマウスイベントや時間に対応させます。基本的にはwasm-bindgenでWebGLのAPIを地道に呼ぶだけですので、Rustで書くのにちょっとコツがいる非同期処理の部分のみ抜粋します。まずはmousemoveイベントのハンドリングです。

let mouse_x = Rc::new(RefCell::new(0));
let mouse_y = Rc::new(RefCell::new(0));

{
    let mouse_x = mouse_x.clone();
    let mouse_y = mouse_y.clone();
    add_event_listener(&canvas, "mousemove", move |event| {
        let mouse_event = event.dyn_into::<web_sys::MouseEvent>().unwrap();
        *mouse_x.borrow_mut() = mouse_event.offset_x();
        *mouse_y.borrow_mut() = mouse_event.offset_y();
    })?;
}

再描画の度にマウス座標を参照したいので、マウス座標を保存しておく必要があります。しかし、Rustのライフタイムにより関数の処理が終了した時点で変数は破棄されてしまいます。そのため、スマートポインタを用いて非同期処理でも変数にアクセスできるようにしています。Rcの参照カウントでクロージャ内外両方のアクセスを可能にし、RefCellでミュータブルな書き換えを可能にしています。 もう一箇所、アニメーションを実行する部分に関しても簡単に説明します。参考文献ではsetTimeoutを用いていますが、ブラウザの再描画タイミングに合わせて計算を行いたいのでrequestAnimationFrameを用います。requestAnimationFrameは再帰的に呼び出す必要があるので、それを行う関数を定義します。

fn start_animation<T>(mut handler: T)
where T: 'static + FnMut()
{ 
    let f = Rc::new(RefCell::new(None));
    let g = f.clone();
    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        handler();
        request_animation_frame(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    request_animation_frame(g.borrow().as_ref().unwrap());
}

再帰処理で意識しなければいけないのは、クロージャをクロージャの内外両方で呼ぶ必要があることです。すなわちクロージャへの参照が複数存在することになるので、Rcを使う必要があります。また、Rustではクロージャから代入前の変数にアクセスできないので、事前にミュータブルなメモリ領域を確保してから、クロージャを保存しています。

球体の描画

ここまででレイトレーシング法の準備は完了です。いよいよ図形を描画していきます。レイトレーシング法ではひたすらGLSLでシェーダを書いていきます。Rustはもう書きません(笑)。

今回はレイトレーシング法の中でもレイマーチング法と呼ばれる手法を用います。https://wgld.org/d/glsl/g009.html を参考にフラグメントシェーダを書き換えると、簡単に球体を描画できます。レイマーチング法では距離関数により図形を定義します。距離関数は走査中の光線と物体の距離を表す関数です。レイマーチング法では距離関数が0になった場合に図形と衝突したとみなします(実際の計算では0に十分近づいたら衝突とみなします)。以下は(0,0,0)(0, 0, 0)に球を置いた場合の距離関数です。引数のpは光線の先端の位置ベクトルです。

const float sphereSize = 1.0; // 球の半径

float distanceFunc(vec3 p){
    return length(p) - sphereSize;
}

球体を増やす

1つの球体を表示できたので、球体を2つに増やしてみましょう。2つの球体は別々の位置に配置する必要があるので、まずは球体の位置をずらします。

const float sphereSize = 1.0; // 球の半径
const vec3 sphereCenter = (1.0, 0.0, 0.0); // 球の中心座標

float distanceFunc(vec3 p){
    return length(p - sphereCenter) - sphereSize;
}

球体の位置をずらすのは簡単で、光線の位置ベクトルから球の中心の位置ベクトルを引くだけです。 物体を増やす場合には、それぞれの物体への距離を計算して最小値を取ります。

const float sphereSize = 1.0; // 球の半径
const vec3 sphere1Center = (1.0, 0.0, 0.0); // 球1の中心座標
const vec3 sphere2Center = (-1.0, 0.0, 0.0); // 球2の中心座標

float distanceFunc(vec3 p){
    float sphere1Distance = length(p - sphere1Center) - sphereSize;
    float sphere2Distance = length(p - sphere2Center) - sphereSize;

    return min(sphere1, sphere2);
}

球体をスムーズにつなげる

メタボールはWikipediaに説明がありますが、距離空間の式ではないのでこのままでは実装できません。今回は https://wgld.org/d/glsl/g016.html で紹介されているsmoothMin関数を用います。各物体との距離にこの関数を適用すると、いい感じに図形が補完されて球が滑らかに結合されます。マウスの位置によって球の距離を変化させるようにすると、最終的に以下のようなコードになります。

precision mediump float;
uniform float time;
uniform vec2  mouse;
uniform vec2  resolution;
const float sphereSize = 0.5; // 球の半径

float smoothMin(float d1, float d2, float k){
    float h = exp(-k * d1) + exp(-k * d2);
    return -log(h) / k;
}

float distanceFunc(vec3 p){
    float normalizedMouseX = (mouse.x * 2.0 - 1.0);
    float distance = abs(normalizedMouseX) * 3.0;
    vec3 sphere1Center = vec3(distance / 2.0, 0.0, 0.0);
    vec3 sphere2Center = vec3(-distance / 2.0, 0.0, 0.0);
    float sphere1 = length(p - sphere1Center) - sphereSize;
    float sphere2 = length(p - sphere2Center) - sphereSize;
    return smoothMin(sphere1, sphere2, 2.0);
}

まとめ

Rust+WebAssemblyでメタボールを実装することができました。結局GLSLばかり書くことになってしまったので、次はちゃんとRust+WebAssemblyをやろうと思います(笑)。