ブラウザ上でヌルヌル動作する流体シミュレーションを Rust + wasm-bindgen-rayon で実装する
この記事は Rust Advent Calender 2024 の 4 日目の記事です.
Rust と wasm-bindgen-rayon を使って,ブラウザ上で動作する流体シミュレーションを実装しました.マウスで流体をグリグリいじれるようになっているので,遊んでみてください.
Demo(キャンバスの描画に数秒かかるかもしれません.動作には SharedArrayBuffer に対応しているブラウザが必要です):
Repository :
自分の環境では,12 スレッドを使って 10,000 個の粒子を 60FPS で処理できているようです.粒子の個数を変えられるようになっているので,パラメータをいじりながらパフォーマンスの変化を見てみるのも面白いと思います.
作った動機
この流体シミュレーションを作ったきっかけは,Sebastian Lague の以下の動画を見たことです.
この動画では,粒子法に基づく流体シミュレーションを 1 から実装していき,最終的には素晴らしいクオリティーのシミュレーションを実現しています.
ただ,上記のシミュレーションは Unity 上で動くように作られているため,手元で手軽に実行するにはややハードルが高いです.ブラウザ上で手軽に動かせるシミュレーションがあれば,もっと気軽に試せるはずです.そこで,似たような流体シミュレーションを Rust で実装して WASM にコンパイルし,ブラウザ上で動かせるようにしたのが今回の成果物となります.
シミュレーションを実装するにあたって,粒子の数がある程度多くなってもヌルヌル動くようにしたいです.そこで,rayon クレートを用いてシミュレーションを並列化することにしました.
流体シミュレーションの概要
この記事では,流体シミュレーションの物理的側面に深く立ち入ることはしませんが,今回実装したソルバがどういう計算をしているのかざっくり把握できるように,シミュレーションの概要を簡単に説明します.
今回作った流体シミュレーションは,Smoothed Particle Hydrodynamics(SPH, SPH 法)という手法に基づくものです.SPH 法では,流体を有限個の粒子の集団とみなして,それらの粒子の動きを通じて流体の物理的挙動をシミュレーションします.また,それらの各粒子は質量,圧力,密度といった物理量を持ちます.シミュレーションの各タイムステップでは,各粒子について,近傍粒子の物理量を適当に重みづけして足し合わせていくことで,次のタイムステップのための物理量を計算します.
SPH 法は,以下の 3 ステップからなります.
- 各粒子の圧力・密度の計算
- 各粒子の力の計算
- 各粒子の速度の更新
各ステップについて,対応する Rust のコードも交えながら簡単に説明していきます.なお,ソルバの処理内容を把握するための最低限の説明しかしないので,SPH 法自体についてもっと詳しく知りたい場合には末尾の 参考文献 のところを参照してください.
密度・圧力の計算
SPH 法では,各粒子の密度を,その粒子から一定距離
まとめると,粒子
ここで,
SPH 法で粒子にかかる圧力を計算する方法はいくつか提案されていますが,今回は粒子
ここで,
以上の密度・圧力の計算を Rust のコードに落とし込むと次のようになります.なお,今回は粒子によらず質量が一定であるとし,その質量はコード中でMASS
と表します.
particles.iter_mut().enumerate().for_each(|(i, particle)|{
let pi = &particles_copy[i];
particle.density = 0.0;
particles_copy.iter().for_each(|pj|{
let r = (pi.position - pj.position).length();
if r < KERNEL_RADIUS {
particle.density += MASS * DensityKernel(pi.position - pj.position);
}
});
particle.pressure = k * (particle.density - TARGET_DENSITY);
});
DensityKernel
がカーネル関数 TARGET_DENSITY
が基準密度 for_each
の中でparticles
を借用することはできないので,particles
のコピーを借用するようにしています.
力の計算
粒子の運動方程式は,以下のナビエ・ストークス方程式により表されます.
右辺は,それぞれ圧力項,粘性項,外力項であり,
圧力項,粘性項の離散化には様々な定式化がありますが,今回用いたのは以下のような式です.
つまり,各粒子の力の計算は以下のようなコードで表されます.圧力項がfpress
,粘性項がfvisc
です.
particles.iter_mut().enumerate().for_each(|(i, particle)|{
let pi = &particles_copy[i];
let mut fpress = Vec2::new(0.0, 0.0);
let mut fvisc = Vec2::new(0.0, 0.0);
particles_copy.iter().enumerate().for_each(|(j, pj)|{
if i != j {
let r = (pi.position - pj.position).length();
if r < KERNEL_RADIUS {
let shared_pressure = (pi.pressure + pj.pressure) / 2;
fpress += -MASS * shared_pressure * PressureKernelGradient(pi.position - pj.position) / pj.density;
fvisc += MASS * (pj.velocity - pi.velocity) * ViscosityKernelLaplacian(pi.position - pj.position) / pj.density
}
}
});
fvisc *= VISCOSITY;
let fgrv = pi.density * GRAVITY;
particle.force = fpress + fvisc + fgrv;
});
式ではカーネル関数をすべて DensityKernel
, PressureKernelGradient
, ViscosityKernelLaplacian
のようにカーネル関数の使い分けをしています.
速度・位置の更新
力が求まったので,それを密度
という式に従って速度や位置を更新していきます.
近傍探索
先ほど示したコードでは,各粒子の近傍粒子をナイーブに全探索していましたが,これでは粒子数が多くなったときにパフォーマンスが急激に悪化してしまいます.それを避けるために非常に有効なのが,平面を格子状に分割し,探索空間を限定することです.以下のように,平面全体を,カーネル半径
すると,各粒子について,その粒子から距離
今回は,各マスについて,そのマスに含まれる粒子のインデックスをVec<u32>
として持つように実装しています.格子全体はVec<Vec<u32>>
のような 2 次元ベクタとして保持します.
並列化
今回は,シミュレーションの並列化のために rayon クレートを使いました.rayon は,Rust における主要な並列処理ライブラリの一つであり,イテレータを少し書き換えるだけで簡単にマルチスレッドによる並列化ができます.
SPH 法は,各粒子の物理量の計算を独立に行うため,割と素直に並列化できます.たとえば,密度や力を計算するコードであれば,以下のようにiter_mut()
をpar_iter_mut()
に変えるだけで並列化ができます.
particles.par_iter_mut().enumerate().for_each(|(i, particle)|{
...
});
wasm-bindgen と wasm-bindgen-rayon について
ここでは,Rust と JavaScript の相互運用に用いた wasm-bindgen と、WASM でマルチスレッドによる並列処理をするために用いた wasm-bindgen-rayon について説明します.
wasm-bindgen
Rust コードをコンパイルしてできた WASM モジュールをブラウザ上で実際に動かすためには,WASM モジュールと JavaScript との間でのやりとりが必要になります.その橋渡しをよしなにやってくれるのが,wasm-bindgen です.wasm-bindgen を使うと,Rust で記述した関数を JavaScript 側から呼び出したり,Rust で定義した構造体を JavaScript 側で使うことができるようになります.たとえば,今回の流体シミュレーションでは,以下のように#[wasm_bindgen]
をつけてstart
関数を宣言しています.
#[wasm_bindgen]
pub fn start() -> Result<(), JsValue> {
// シミュレーションを開始するコード
}
これにより,JavaScript 側から上記のstart
関数を呼び出せるよう,WASM モジュールと JavaScript との橋渡しをするコードを wasm-bindgen が生成してくれます.JavaScript からは,そのコードを import するだけでstart
関数を使えます.
なお,wasm-bindgen は,実際にはwasm-packというツールを通して間接的に呼ばれることが標準的です(今回のケースでもそうです).ここでは詳しい説明を省きますが,wasm-bindgen や wasm-pack など,Rust ⇒ WASM の変換に関するツールの関係性については MDN のチュートリアルが詳しいです.
wasm-bindgen-rayon
WASM でマルチスレッドによる処理をする場合,現状では WebAssembly threads を使うことになります.Rust で書いた並列処理のコードから WebAssembly threads を使う方法はいくつかあるのですが,wasm-bindgen-rayon を使うのが現時点で最もポピュラーな選択肢だと思います。wasm-bindgen-rayon を使うと,rayon を使って書いた並列処理のコードをそのままブラウザ上でも使えるので便利です.
基本的には,公式レポジトリ の README に沿ってセッティングしていけば特に問題なく使えます.ただ,執筆時点の wasm-bindgen の最新版である0.2.95
に問題があり,workerHelpers.worker.js
が正しく生成されないという問題があるようです(関連する issue).0.2.93
の wasm-bindgen だとこの問題は起きないので,問題が修正されるまでは0.2.93
の wasm-bindgen を使っておくのが安全そうです.
また,セキュリティ上の理由により,WebAssembly threads をブラウザ上で使うためには COEP ヘッダーや COOP ヘッダーの設定を適切に行う ことが必要になります.
実際に動かしてみる & ベンチマーク
シングルスレッドの場合と 12 スレッドを使った場合で,10,000 粒子をシミュレーションするパフォーマンスの比較をしてみました.なお,以下のシミュレーションは AMD Ryzen 7 5825U 上で動作させています.
-
シングルスレッド
-
12 スレッド
60FPS でシミュレーションを動作させたい場合,1 フレームの処理にかかる時間が 1000/60 = 16.6ms を下回るようにすることが目標になります.この時間を計測したところ,シングルスレッドの場合は約 90ms 程度だったのに対し,12 スレッドを使った場合には約 14ms 程度でした.上記の画像を見ると,その差がシミュレーションのスムーズさにもたらす影響がよくわかると思います.実装した肌感覚として,SPH 法による流体シミュレーションでは,並列化なしでこのパフォーマンスを出すのはかなり厳しそうです.
まとめ & 所感
以下がまとめです.
- ブラウザ上で動作する流体シミュレーションを Rust で実装した.
- wasm-bindgen-rayon 経由で WebAssembly threads を用いることにより,マルチスレッドによる並列化を行った.
- シミュレーションには SPH 法を用いた.
- 12 スレッドを用いることで,10,000 個の粒子を 60FPS で処理できている.
今回作ったシミュレーションのたたき台は,Rust ではなく JavaScript を使って実装したのですが,型に関するエラーを見逃しやすく,バグの原因を特定するのに結構な時間を費やしました.また,演算子オーバーロードがないため,ベクトルの計算にいちいちメソッドを呼ばなければならないのも,地味にきつかったです.一方,思い切って Rust に切り替えた後は,ガチガチの型チェックのもと,ソルバのロジック部分に集中することができて快適でした.また,Rust を WASM に変換し,ブラウザ上で動かすためのエコシステム(wasm-pack や wasm-bindgen まわり)がかなり整備されてきているのも良かったです.
今後は,今回作ったシミュレーションを三次元に拡張しようと目論んでいます.その際,計算量が二次元の場合よりも増大することは避けられないので,WebGPU という API を用いて GPGPU の並列処理能力を借りようと思っています.実は,すでにたたき台はできており,近傍粒子を全探索する場合でも 20,000 粒子をそこそこの速度で処理できているようです.GPGPU すごい.
WebGPU を用いた 20,000 粒子のシミュレーション
これから実装する,空間の格子分割による高速化により,どれくらいの規模の粒子を扱えるようになるのかが楽しみなところです.
参考文献
-
Coding Adventure: Simulating Fluids
- 今回のシミュレーションを作るモチベーションとなった,Sebastian Lague の動画です.シミュレーションの各ステップについて,実装に落とし込める粒度で詳しく説明されています.
-
Particle-Based Fluid Simulation for Interactive Applications, Müller et al. 2003
- Sebastian Lague の動画では,この論文に基づいて SPH 法の実装を行っています.非常に有名な論文です.
-
Particle-based Viscoelastic Fluid Simulation, Clavet et al. 2005
- 記事では説明しませんでしたが,今回の流体シミュレーションでは,この論文で提案された near-density と near-pressure というテクニックを実装しています(Sebastian Lague の動画内でも言及があります).このテクニックは表面張力をシミュレーションするのに効果的で,実装すると液体っぽさがグッと増します.
- Lucas V. Schuermann という方の,SPH 法に関する記事や実装を大いに参考にしました.
- Implementing SPH in 2D (SPH 法に基づくシンプルなソルバを実装する記事)
- mueller-sph (Müller の論文に基づく流体シミュレーションの簡潔な実装)
-
Rust+WebAssemblyでMDNのWebGLチュートリアルを写経する
- 今回のシミュレーションでは,Rust から WebGL の API を呼んで描画を行っています.その実装にあたって,この記事を参考にしました.
Discussion