[Astro] #189 GPUだけで数十万粒子を動かす NEBULA — マウスとQuest 3の手で払う星雲
はじめに
Three.js で、まだ使っていない機能を使った作品を作りました。GPGPU(GPU で粒子の計算までやる方法)です。粒子の位置と速度を、毎フレーム GPU の中だけで更新します。CPU は、粒子に一切触りません。
ページは /tool/nebula/index.html です。PC のブラウザではマウスで、スマホでは指で、Quest 3 ではコントローラーや手で、粒子を払って遊べます。
結論から言うと、Quest 3 で約 26 万粒子(512×512)が、72Hz のまま動きました。 ただし、ハンドトラッキング、MR の見た目、90/120Hz は、実機でまだ確認していません。詳しくは、最後の「確認できたこと、できていないこと」に書きました。
スクリーンショット
銀河(起動画面)
形と配色を切り替えたところ
VR 内の自動ベンチ結果
作ったもの
- 5つの形: 銀河、球雲、環、文字、渦(アトラクタ)
- 4つの配色: 星雲、WIRED、炎、氷
- 粒子数: 辺の長さを 128〜1024 の 9 段階で選べます(粒子数は辺の 2 乗。16,384〜1,048,576)
- 操作: マウス、タッチ、キーボード、Quest 3 のコントローラー、手
- 自動ベンチ: 粒子数を段階的に上げて、FPS を測ります(VR 内にも結果を出します)
- 調整パネル: 粒の大きさ、力の強さなどを、G キーで変えられます
普段の画面は、できるだけ少なくしました。左下に ‹ GALAXY › [COLOR] だけを置き、細かい説明は F1 のヘルプに入れています。
| 操作 | 内容 |
|---|---|
| マウスを動かす | 粒子を払う |
| 左ボタン長押し | 引き寄せる(カーソルの周りを回る輪になる) |
| 右ドラッグ / ホイール | 視点の回転 / ズーム |
1〜5 / T | 形 / 配色 |
[ ] | 粒子数の減 / 増 |
Space | バースト(中心から弾ける) |
H / B / G | 計測表示 / 自動ベンチ / 調整パネル |
| 指でなぞる | 払う(スマホ) |
[PULL] | オンにすると、指で引き寄せる(スマホ) |
| 2本指 | ズームと回転(スマホ) |
GPGPU の仕組み
位置と速度をテクスチャに持つ
粒子 1 個ぶんの位置を、テクスチャの 1 ピクセルに入れます。位置のテクスチャと、速度のテクスチャの 2 枚です。辺が 512 なら、512×512 = 262,144 個の粒子になります。
更新は、GPUComputationRenderer(Three.js のアドオン)に任せました。位置と速度のそれぞれに、フラグメントシェーダーを 1 本書きます。
- 速度のシェーダー: 今の位置と速度から、力を計算して、速度を更新します
- 位置のシェーダー: 速度を足して、位置を更新します
GPUComputationRenderer は、読み取り用と書き込み用の 2 枚を、毎フレーム入れ替えます(ping-pong)。これで、同じテクスチャを読みながら書く問題を避けられます。CPU から GPU へ、粒子のデータを送ることは、起動時の初期値以外にありません。
浮動小数点テクスチャ
位置と速度を、32 bit の浮動小数点で持ちます。EXT_color_buffer_float に対応していない GPU では、16 bit(HalfFloat)に切り替えます。URL に ?half=1 を付けると、強制的に半精度にできます。
描画
描画は、THREE.Points に、自前の ShaderMaterial を付けました。頂点シェーダーが、位置と速度のテクスチャを、粒子ごとの参照座標(ref)で読み取ります。
- 色は、速度のテクスチャの w 成分に入れた「キー」(形ごとに決める 0〜1 の値)から、4 色のグラデーションを引きます
- 速く動いている粒子ほど、明るく、少し大きくします
- 乱数で、粒の大きさ、明るさ、ときどき白い星を混ぜます
- 加算合成で描きます。重なった所が明るくなります
粒の大きさは、画面上の大きさが小さくなりすぎないように下限を決めました。膨らませた分は、暗くして、全体の明るさを保ちます。
5つの形
粒子は、形ごとに「戻りたい場所(home)」を持ちます。速度のシェーダーは、今の位置から home へ戻るばねの力を足します。払われて散った粒子が、離すとゆっくり元の形に戻るのは、このためです。
home は、粒子の番号から作った乱数(PCG ハッシュ)で、毎フレーム計算します。テクスチャに保存はしません。
- 銀河: 中心の丸い塊(バルジ)と、2 本の腕。中心付近は、角度を大きくばらけさせます。そうしないと、2 本の腕が中心で一直線の「棒」になりました
- 球雲: 薄い殻と、内部のもや。表面は、ゆっくりうねります
- 環: ねじれた、平たいリボンです
- 文字: 2D キャンバスに文字を描いて、その点の座標を、テクスチャで渡します(
?text=好きな文字で変えられます)。読めるように、揺らぎは弱めています - 渦: アトラクタ(Aizawa)の流れに、粒子を乗せます。ばねは使いません
これらに、ABC フロー(発散のない流れ)の揺らぎを、2 段重ねて足しています。形が静止画にならず、生き物のように動きます。
アトラクタで、粒子が 1 点に固まった
渦の形で、時間が経つと、粒子が 1 点に集まってしまいました。Aizawa アトラクタには、軸の上に、安定した点があります。そこへ入った粒子は、二度と出てきません。
直したのは、次の 2 つです。
- 軸の近くに吸われた粒子や、遠くへ飛んだ粒子を、アトラクタの近くへ再配置する(位置のシェーダー側)
- 速度場への追従を、指数関数で緩和する(
v += (tv - v) * (1.0 - exp(-9.0 * uDt)))。普通に足すと、フレーム時間が長いときに、値が発散します
力場
マウス、コントローラー、指先を、すべて同じ「力場」として扱います。最大 16 個です。
uniform vec4 uPts[16]; // xyz=位置, w=半径
uniform vec4 uFrc[16]; // x=放射(+押す/-引く) y=渦 z=引きずり
uniform vec4 uAxis[16]; // 渦の軸
uniform vec4 uPvel[16]; // 点の速度
速度のシェーダーは、半径の内側にいる粒子に、次の 3 つの力を足します。
- 放射: 正なら押し出し、負なら引き寄せます。引き寄せのときは、中心の手前(0.05 m)で力を弱めます。そうしないと、粒子が中心に潰れて、点になります。周回して、輪になります
- 渦: 軸と、粒子の位置の外積の向きに、力をかけます
- 引きずり: 力場の動く速度に、粒子の速度を寄せます。手を振ると、粒子が一緒に流れます
力の強さは、距離に応じて、滑らかに(smoothstep で)弱めています。
マウスの位置を、3D の力場にする
PC では、マウスの位置から、画面に向かう線を引き、星雲の中心を通って視線に垂直な平面と交わる点を、力場の位置にします。奥行きが、視点に合わせて、常に星雲の中心になります。
タッチ
指は、ホバーができません。そこで、1 本指でなぞると払う、[PULL] をオンにしてなぞると引き寄せるにしました。2 本指は、ズームと回転です。1 本指は、OrbitControls から外してあります。
controls.mouseButtons = { LEFT: -1, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
controls.touches = { ONE: -1, TWO: THREE.TOUCH.DOLLY_ROTATE };
VR / MR(Quest 3)
renderer.xr.enabled = true にして、ENTER VR と ENTER MR のボタンを出します(対応するブラウザだけ)。
- コントローラー: 先端で払います。トリガーが引き寄せ、グリップが渦です
- 手: 指先で払います。人差し指と親指のピンチが引き寄せ、中指と親指のピンチが渦です
- 手前の光る玉: 触れると、形・配色・品質・計測を切り替えます。コントローラーも手も使えない場面のためです
- ボタン: 右 A=形、右 B=配色、左 X=品質、左 Y=計測、右スティック押し込み=バースト、左スティック押し込み=ベンチ開始・中止
VR では、最初の粒子数を軽め(辺 256、約 65,000 個)にしました。計測表示を見ながら、品質の玉で上げます。
計測表示とベンチの結果は、VR の中では、CanvasTexture を貼った平面に出しています。
自動ベンチ
「何個まで動くか」を、人が数えなくても分かるようにしました。粒子数を段階的に上げて、FPS を測ります。
- 段階は、辺 128、256、384、512、768、1024 です
- 各段階で、1.8 秒の準備のあと、4 秒間測ります
- 力場が 2 つ、星雲の周りを回ります。実際に払ったときの重さを、手を使わずに再現するためです
- 基準の FPS(VR では、セッションのフレームレート。PC では、最も良かった段階)に対する割合で、
++(97% 以上)、+(85% 以上)、~(65% 以上)、--と判定します - 基準の半分を下回ったら、そこで測定を打ち切ります
- 結果は、Markdown の表にして、ボタンでコピーできます
起動は、PC では B キー、VR では左スティックの押し込みか、玉です。?bench=1 を付けると、開いた直後(VR は入った 2.5 秒後)に、自動で始まります。?hz=90 で、VR のフレームレートを updateTargetFrameRate で要求できます。
Quest 3 での実測
| 粒子数(辺) | 結果 |
|---|---|
| 128〜512(約 26 万個まで) | 72Hz を維持 |
| 768(約 59 万個) | 約 38 FPS |
| 1024(約 105 万個) | 約 15 FPS |
端末は Quest 3(Adreno 740)で、VR のフレームバッファは 3360×1760 でした。浮動小数点のテクスチャも、問題なく使えました。768 と 1024 の値は、VR 内の結果パネルを録画した映像から読み取っています。小数点以下は、読み違えているかもしれません。
詰まったこと
getFramebufferScaleFactor が、存在しない
診断の表示に、VR の解像度の倍率を出そうとして、renderer.xr.getFramebufferScaleFactor() を呼びました。Three.js r186 には、この関数がありません。ベンチも診断も、そこで止まりました。
倍率は、URL の fb の値から、自分で取るようにしました(無ければ 1)。診断の組み立て全体も、try / catch で包みました。診断が失敗しても、本体が止まらないようにするためです。
VR の計測パネルの文字が、右端で切れた
CanvasTexture に描いた文字が、右端で切れました。フォントを 26px から 21px に下げて、収まりました。VR のパネルは、PC の画面と違って、はみ出しが見えにくいので、実際に Quest で見て分かりました。
Astro の開発サーバーでは、public/<フォルダ>/ が index.html に解決されない
NEBULA は、public/tool/nebula/index.html に置いています。本番の静的ホスティングでは、/tool/nebula/ だけで開けます。ところが、Astro の開発サーバーでは 404 になります。/tool/nebula/index.html と、ファイル名まで書く必要があります。
私の手元の簡易サーバー(python -m http.server)は、フォルダだけで開けたので、この違いに気づきませんでした。Quest から開発サーバーに入る場合は、ファイル名まで入力します。
Quest では、コンソールが見られない
読み込みの失敗や、実行時のエラーが、Quest 上では分かりません。そこで、index.html の先頭に、エラーを画面の下に赤い枠で出す小さなスクリプトを置きました。モジュールの読み込みに失敗したときも、出ます。
WebXR は、HTTPS か localhost でしか動かない
PC の開発サーバーを Quest から開くには、HTTPS が必要です。ローカルの開発サーバーを、HTTPS で立てています。
中指ピンチは、エミュレーターで再現できなかった
テストは、IWER(Quest 3 のエミュレーター)と、Playwright で動かしたヘッドレスの Chromium で行いました。コントローラー、人差し指のピンチ、光る玉への接触は、これで確認できました。中指のピンチだけは、再現できませんでした。 この部分は、コードを読み直しただけです。
また、ヘッドレスの Chromium は、ソフトウェア描画(SwiftShader)なので、FPS の値には意味がありません。速度は、必ず実機で測りました。
ギャラリーへの追加
過去作品ギャラリー(/works)に、1 件足しました。works.json に、縦長のサムネイル(thumb)と、カードを拡大したときに再生する動画(video)を書きます。
VR の動画も見せたいので、videoVr(省略可)を足しました。これがある作品だけ、拡大したカードに [PC] [VR] の切り替えが出ます。
動画は、音なしで再圧縮して、1MB 前後にしました。
ffmpeg -i in.mp4 -an -c:v libx264 -crf 34 -preset veryslow -pix_fmt yuv420p -movflags +faststart out.mp4
Quest の録画は横長なので、縦長のカードに合わせるときは、横を切らずに、黒い余白を足しました。
-vf "scale=720:-2,pad=720:1080:0:(oh-ih)/2:black"
確認できたこと、できていないこと
確認できたこと
- PC のブラウザで、5つの形、4つの配色、マウスの払い・引き寄せ・バースト、粒子数の増減、調整パネル、半精度への切り替え
- Quest 3 で、VR の開始、コントローラーでの操作、光る玉での切り替え、自動ベンチ(26 万粒子まで 72Hz)
- エミュレーターで、コントローラー、人差し指のピンチ、玉への接触、VR 内のベンチパネル、終了後の PC 設定への復帰
- スマホ幅の画面と、タッチの操作(エミュレーションでの確認。1 本指で払う、
[PULL]で引き寄せる、1 本指でカメラが動かない) - ギャラリーの拡大カードで、動画の再生と、
[PC][VR]の切り替え
確認できていないこと
- 実機のハンドトラッキングでの触り心地と、力の強さが適切か
- 中指ピンチの渦(コードを読んだだけ)
- MR の見た目(加算合成が、パススルーの映像にどう乗るか)
- 90Hz と 120Hz
- 実機のスマホ・タブレットでのタッチ操作と、2 本指のズーム・回転
- Safari と Firefox での動作
おわりに
GPGPU の考え方は、「データをテクスチャに入れて、計算をシェーダーに書く」だけでした。GPUComputationRenderer を使えば、ping-pong の面倒な部分は、任せられます。
詰まったのは、シェーダーの計算より、Three.js のバージョンごとの API の違いと、開発サーバーと本番のホスティングの違いのような、周辺でした。どちらも、実機で動かして、初めて分かりました。
次は、弱い GPU でも重くならないように、FPS が低いときに、粒子数を自動で下げる処理を足す予定です。