【WebGPU】ブラウザ上でDEMの大量リアルタイムシミュレーションをやってみる

だいぶ前の記事でCPUで大量の粒子を計算することをやりました。

このときは、あまり大量の粒子をけいさんすることはできませんでしたが、今回のGPUを使った計算では、以下のように万を超える粒子を”ほぼ”リアルタイムで計算することができました

2万粒子での計算結果

実行環境は、GPU: GeForce RTX 3060Ti、CPU: Intel Core i7-12700 2.10 GHzです。

GPUは、CPUが数個~数十個のコアを持つのに対し、GPUは数千個の小さなコアを持ち、多数の演算を同時並行で処理できます。

WebGPUはWeb上でGPUを使った大規模な並列計算を行える最新の技術です。従来のWebGLと比べて低レベルAPIへのアクセスが可能で、計算処理に特化した機能も備えています。

一方でDEM(個別要素法/Discrete Element Method)は、多数の粒子の挙動をシミュレーションし、それらの相互作用からマクロな現象を再現する解析手法です。土木工学や地盤工学、粉体工学の分野で広く用いられています。

DEMのような大量の粒子間の相互作用計算はGPUの並列処理能力と非常に相性がよく、比較的新しい技術であるWebGPUに適用すれば、ブラウザ上でリアルタイムのシミュレーションができるかなと思って、今回実装しました。

コードとデモページはこちら↓

GitHub - shimotani1028/WebGPU-DEM2D-Demo
Contribute to shimotani1028/WebGPU-DEM2D-Demo development by...

WebGPUは現在、Chrome、Edge、Firefoxなどの一部のモダンブラウザでサポートされていますが、まだ広く対応しているわけではありませんので注意が必要です。対応情報はこちら

DEMについて

個別要素法の詳しい内容については詳しく触れませんが、それほど難しい計算は行っていません。

GPUを使う上で工夫した点は、粒子の接触判定の際の隣接粒子探索のプロセスメモリの削減です。

どちらも既往の研究でいろいろな方法がありましたが、今回のコードは私が理解できる範囲で、比較的簡単な方法を用いているので、最適ではないと思います。

今後より高速に計算するためには、このあたりの処理をうまく効率化することが重要になると考えています。

参考にしたサイトをページ下部に載せておきますので、そちらを参考にしていただければと思います。

実装コード

今回実装したコードは main.jsに集約されています。index.htmlは計算結果を表示するためのUIを提供します。ここでは、main.jsの主要な部分について解説します。

全体の流れ

main.jsの処理は大きく分けて以下の5つのステップで構成されています

  1. 初期化:WebGPUの利用準備として、デバイスやアダプタの取得と設定
  2. キャンバス設定:HTML上の描画領域とWebGPUの連携
  3. データバッファ準備:粒子情報(位置、速度、半径など)のGPUメモリ確保
  4. 計算処理:粒子の物理演算をコンピュートシェーダーで実行
  5. 描画処理:計算結果を視覚化するレンダリング

以下、コードをかいつまんで解説します。

初期化

グローバル変数・パラメータの読み取り

URLパラメータから設定値を読み取る仕組みです。これにより、URLを変えるだけでシミュレーションの条件を簡単に調整できるようにしました。

主な設定パラメータ:

  • min_radius/max_radius: 粒子の最小/最大サイズ(m)
  • balls: シミュレーションする粒子の総数
  • width/height: 描画領域のサイズ(px)
  • width_length: シミュレーション空間の実際の幅(m)
  • x1,y1,x2,y2: 固定線分の座標(m)

GPUリソースの初期化

このコードは、WebGPUの基本的なセットアップを行います:

  1. まずブラウザがWebGPUに対応しているか確認
  2. adapterの取得(GPUとやり取りするためのインターフェース)
  3. deviceの取得(実際の計算リクエストを送るオブジェクト)

requiredLimitsでストレージバッファの上限値を引き上げて、より多くのストレージバッファを使えるようにしています。

キャンバス設定

index.htmlで準備された <canvas id="webgpuCanvas">に幅と高さを設定して webgpuコンテキスト(getContext("2d")のようなもの)を取得します。そして、canvas要素とWebGPUを連携させます

configureで実際に描画に用いるピクセルフォーマットを指定します(formatで現在のシステムで最適なものが適用される)。

この設定により、後ほどGPUで計算した結果をブラウザ上に表示できるようになります(私もこのあたりはよくわかっていない部分があります)。

データバッファの準備

GPUでの計算で用いる粒子の初期値の準備とバッファメモリの作成を行います。

最後に device.queue.writeBufferでバッファメモリに書き込みを行います。

このあたりがWebGPUを使う上でややこしくなってくるポイントだと思います。

基本的な流れは、

  1. バッファをあらかじめ準備(バッファの種類やサイズなど)
  2. 書き込みを行ってGPU内の計算で使用

です。

Float32Array(new ArrayBuffer(BUFFER_SIZE))について、

ですので、粒子数(12000個) ✕ 各粒子の情報(位置、速度、…)がここでは8個(BUFFER_COUNT) ✕ 情報のそれぞれが単精度の浮動小数点数(=4byte)=384000のバッファサイズの配列ということです。

具体的な配列の中身は[粒子番号1の位置x, 粒子番号1の位置y座標, …, 粒子番号nの位置x座標, 粒子番号nの位置y座標, …]と1次元の長い配列になっています。

計算処理

コンピュートシェーダー

ここでWGSL(WebGPU Shading Language)によるコンピュートシェーダーが記述されています。

いわゆるGPU計算の部分です。

今回はDEMの計算を行うので、粒子同士の衝突の計算や、粒子と壁や線分の衝突の計算を行っています。ワーキンググループが粒子ごとに分かれており、1粒子ごとにmain関数が実行されているイメージです。

ワーキンググループについて

今回は粒子の数だけGPUでスレッド数を割り当てて計算しています。

以下のコードを見てください。

ワーキンググループは、dispatchWorkGroupsでその数が割り当てられます。この上限値は device.limitに記載されている maxComputeWorkgroupsPerDimension: 65535です。

device.limitの出力結果

さらに、先程のコンピュートシェーダで @workgroup_size(64)と指定しているので、各ワーキンググループが64個のスレッドに分けられます。

つまり、この例では、ワーキンググループを粒子数(12000個)/64個=188(小数点切り上げ)個作成し、1つのワーキンググループは64個に分けられるので、結果188✕64個=12032個のスレッドができるため、12000個の粒子を別々のスレッドで扱えることになります。以下イメージ図です。

ワーキンググループとスレッドのイメージ図(1次元のみ抽出)

ちなみに device.limitmaxComputeWorkgroupSizeX : 256 maxComputeWorkgroupSizeY : 256 maxComputeWorkgroupSizeZ : 64とあります。これは @workgroup_size()の上限を表しており、理論的には「(65535 × 256) × (65535 × 256) × (65535 × 64)」がスレッド総数の上限となる計算です。ただし、実際には使うハードウェアにより、制限があるようです。

diapatchWorkGroups()@workgroup_size()は3次元で表すことができ、diapatchWorkGroups(188)diapatchWorkGroups(188, 1, 1)@workgroup_size(64)@workgroup_size(64, 1, 1)と等しいです。

今回は1次元(xのみ)を使っているので、main関数の global_id.xが何番目のスレッドかを表します。なので、その値をそのまま粒子番号として計算しています。

ワーキンググループの説明はこちらがわかりやすかったです。

コンピュートシェーダーへのバッファ受け渡しの流れ

@computemain関数に device.queue.writeBufferで書き込んだ変数がわたるまでの流れは以下のとおりです。

createBindGroupLayoutでどのバインディング番号にどのバッファを割り当てるかを以下で定義します(binding=0→ storageバッファ、binding=3→ uniformバッファ など)。

作成した bingGroupLayoutcreatePipelineLayoutでパイプラインレイアウトに適用して、createComputePipelineで コンピュートシェーダーの main関数をエントリーポイントとして紐づけて、パイプラインを作成します。

まだ作成したバッファメモリの変数は対応付けられていません。

以下で、 createBundGroupbindGroupLayoutと作成した変数を対応付けた(binding=0はinputバッファ、binding=1はoutputバッファ のように)bindGroupを作成します。

最後に、device.createCommandEncoderで作成したコマンドエンコーダーにパイプラインをセットし、setBindGroup@group(0)と 作成した bindGroup1@group(1)bindGroup2を対応させるように指示します。

これで、main()関数に bindGroupで指定したバッファ変数が渡されます。

描画処理

WGSLを用いて計算結果を canvas要素に描画します。

シェーダーモジュール

レンダリングのシェーダーモジュールは以下のように頂点(vertex)フラグメント(fragment)に分かれています。

頂点シェーダーは描画する点の位置を記述します。フラグメントシェーダーはを記述します。

頂点シェーダー @vertex

vertexMain関数について、

@builtin(vertex_index)“今処理している頂点が何番目か”を示す変数

@builtin(instance_index)“今処理しているインスタンスが何番目か(何番目の粒子か)”を示す変数

@location(0) pos: vec2<f32>は頂点バッファのレイアウトで location=0に対応する変数

となります。

今回の例では、locationでわたってくる円を表す頂点のデータを、コンピュートシェーダーで計算した粒子の位置を使って、適切な位置に表示させるように移動や回転をさせています。

頂点シェーダーへのlocationの変数の受け渡しの流れ

@locationに変数がわたる流れは以下のとおりです。

粒子に対応する頂点の位置を求めて、配列に格納します。粒子の形状を以下のように定義します。

ここでは、半径1の粒子を10の三角形との集合で表すようにしています。以下のようなイメージです。

粒子の表示の元になる頂点データ

1つ欠けさせているのは、粒子自体の回転を表現したかったからです(ただの円だと回転しているか見えづらいから)。

ここで作成した頂点を以下で、vertexBufferというGPUのバッファに書き込みます。

頂点バッファのレイアウトを定義します。@location(0)に対応させるために、shaderLocation: 0としています。

レンダリングのパイプラインを作成するときに、vertexbuffersに上記の vertexBufferLayoutを設定します。まだ、vertexBufferLayoutvertexBufferは対応付けられていません。

最後に描画コマンドを作成するときに、以下のように setVertexBuffer@location(0)vertexBufferを対応させるように指示します。

これで頂点データがシェーダーの @location(0)に渡されます。

フラグメントシェーダー @fragment

続いて、fragmentMain関数について、fragmentMainの引数 inputは、vertexMainの戻り値である VertexOutputを受け取ります。

そして、fragmentMainの戻り値は色を表していて、頂点で形成された三角形のピクセル毎にこの @fragmentが呼び出されるようです。

今回は粒子番号や速度で切り替えられるようにしています。vertexMain関数のvertexOutput.cellColorで計算した色(r, g, b, a)をそのまま表示しています。

メイン関数

上記のコードを準備した後、以下のメイン関数をで計算をrequestAnimationFrameで回します。

requestAnimationFrameは、実行間隔は一定ではないため、前回からの時間間隔を記録しておいて、メイン関数に渡しています

そこからその時間で計算する回数を計算し、その回数分GPUでの計算をコマンドに記載していきます。

計算が終わるとレンダリングを実行します。

DEMは設定パラメータによって、時間ステップが結構短くなる場合があるので、試行錯誤的に決定しています。

描画範囲の拡大・縮小・移動

一応実行結果を見やすくするために、描画範囲の拡大・縮小・移動をできるようにしています。

これらの処理はvertexMainの計算にも関わってくるので、少しめんどうでした。

動作確認

一応GPUで動いているか確認しました。

GPUの3D使用率が上昇していることが確認できました!

まとめ

今回のコードでは、WebGPUを用いた2次元のDEMを実装しました。コンピュートシェーダーを使って、並列計算をすることで、数万の粒子をリアルタイムで描画できるのは、これまでの経験上すごく魅力的な体験でした。

今後3次元化や描画方法の見直しを行うことで、より面白い計算が行えると思います。

あと最近はコードを調べるにしてもChatGPT等の生成AIに聞くことがほとんどで、今回のコードもだいぶ助けられました。なので、今回の記事のコードの解説などはそのうちなくなって行くんですかね。

参考サイト

DEM関係

WebGPU関係

コメント

タイトルとURLをコピーしました