[Astro] #189 GPUだけで数十万粒子を動かす NEBULA — マウスとQuest 3の手で払う星雲

[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 は、実機でまだ確認していません。詳しくは、最後の「確認できたこと、できていないこと」に書きました。

スクリーンショット

銀河(起動画面)

NEBULA — 銀河の形

形と配色を切り替えたところ

NEBULA — 環の形と配色の切り替え

VR 内の自動ベンチ結果

NEBULA — Quest 3 の 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 が低いときに、粒子数を自動で下げる処理を足す予定です。