ブラウザで動くスプライトが遅いのはなぜか
GPU は何百万ものスプライトを描けます。それなのに、ほとんどのブラウザゲームは数万で行き詰まります。原因は GPU ではなく JavaScript です。ゲームも、データビジュアライゼーションも、パーティクルエフェクトも、同じ仕事を抱えています。スプライトと呼ばれるたくさんの小さな画像を、毎秒 60 回、新しい位置に描くことです。
私たちは Web 向けのオープンソース 2D レンダラー spryte を作っています。オープンなベンチマークでは、この用途で最も広く使われているライブラリである PixiJS より、動くスプライトを 2〜4 倍速く描けます。しかも JavaScript の量は約 9 分の 1 です。この記事では、時間が実際にどこに消えているのか、私たちが何を変えたのか、そしてあなた自身のマシンでどう数字を確かめられるのかを説明します。
予算は 16.7 ミリ秒
60 Hz で更新される画面では、1 フレームあたり 16.7 ms が与えられます。その間にページはゲームロジックを動かし、GPU に何を描くかを伝え、GPU に描いてもらわなければなりません。締め切りに間に合わなければ、アニメーションはカクつきます。
2D では、GPU が問題になることはめったにありません。テクスチャを貼った小さな四角形を描くのは、GPU にとって最も安い仕事のひとつです。問題はその前のやりとりです。JavaScript は毎フレーム、すべてのスプライトを GPU に説明しなければなりません。スプライト 1 枚の説明に 100 ナノ秒かかるなら、10 万枚で 10 ms。ピクセルを 1 つも描かないうちに、予算の大半が消えてしまいます。
だから 2D レンダラーにとって本当の問いは、スプライト 1 枚あたり、どれだけの JavaScript の処理がかかるのかです。
シーンを記述する 2 つの方法
PixiJS を含め、ほとんどの 2D ライブラリはシーングラフ(保持モード、retained mode とも呼びます)を使います。画面上のものごとにオブジェクトを作り、ツリーにぶら下げ、時間とともにプロパティを変えていきます。ライブラリは毎フレームそのツリーをたどります。各オブジェクトの親からの相対位置を更新し、境界を計算し、ほかとどうまとめるかを決めます。そして四隅の座標を GPU 用のバッファに書き込みます。
これは UI や、ほとんど動かないシーンにはとても良い設計です。入れ子、ヒットテスト、イベントを面倒みてくれますし、何も変わらなければ多くの処理を省けます。
でもゲームでは、ものが動きます。すべてのオブジェクトが毎フレーム変わるなら、そうしたオブジェクトごとの帳簿付けが、毎フレーム、すべてのスプライトについて走ることになります。
もうひとつのやり方がイミディエイトモードです。保持しておくオブジェクトはありません。毎フレーム「この画像をここに描いて」と順番に伝えるだけで、レンダラーは描いたら忘れます。spryte はこの方式です。
draw.begin("#111", 320, 180);
for (const e of enemies) draw.sprite(enemyImage, e.x, e.y, { rotation: e.angle });
draw.end();たどるツリーも、同期を保つべきものもないので、スプライト 1 枚あたりのコストは、GPU が読むメモリにその数値を書き込むコストだけです。
スプライト 1 枚をもっと安く
イミディエイトモードでもタダではありません。spryte の最初のレンダラーをプロファイルしたところ、スプライト 1 枚あたりまだ約 70 ns の JavaScript がかかっていて、PixiJS の特化型パーティクルコンテナに負けていました。時間はオプションの読み取り、変形の計算、スプライトごとに 13 個の数値を書き込む処理に薄く広がっていました。特定の 1 行が遅いわけではないので、行単位のチューニングでは直せません。直したのは、2 つの構造的な変更です。
1. 一括呼び出し。 ゲームは同じ画像のコピーをたくさん描くことがよくあります。弾、パーティクル、コイン。そこで spryte には、画像を 1 回だけ受け取り、位置の配列をまるごと受け取る呼び出しがあります。
const positions = new Float32Array(2 * MAX); // ゲームがすでに更新している x, y のペア
draw.sprites(spark, positions, count);どのテクスチャのどの部分を使うかといった共通の処理は、スプライトごとではなく、呼び出しごとに 1 回で済みます。
2. GPU に送る量を減らす。 以前はスプライト 1 枚ごとに 52 バイトを GPU に送っていました。完全な変形行列、テクスチャ座標、色などです。位置しか違わないコピーなら、そのほとんどは繰り返しのデータです。spryte の一括パスはスプライトごとに 8 バイト、x と y だけを送り、残りは呼び出しごとに 1 回送った値から GPU が埋めます。さらに、位置がもともと Float32Array に入っていれば、spryte はその配列をそのまま GPU に渡します。スプライトに対する JavaScript のループは一切ありません。
本物のパーティクルはサイズ、回転、色も変わるので、それらは追加の配列として渡せて、それぞれそのままアップロードされます。色は 32 ビットの数値 1 つに詰めたまま送り、GPU 上で展開します。
どれもグラフィックスプログラマーにとっては目新しいものではありません。ネイティブのゲームエンジンは何十年も前からやっていて、インスタンシングと呼ばれています。珍しいのは、それをゲームエンジンなしで使える、小さくて素直なブラウザ向けライブラリにまとめたことです。
自分をだまさずに測る
レンダラーのベンチマークは、うっかり不正になりがちです。ライバルのライブラリを遅いデフォルト設定のまま放っておく、自分の設計に有利なシーンを選ぶ、運のいい 1 回だけを計測する。私たちは spryte の作者なので、都合のいい結果を望む理由はいくらでもあり、自分たちで書いた結果を疑う理由もいくらでもあります。
そこで、どんな 2D レンダラーでも参加できるオープンなベンチマーク 2dbench を作りました。ルールは次のとおりです。
- 同じ仕事。 シードを固定した共通のシミュレーションがすべてを動かします。ライブラリは描くだけです。
- 同じピクセル。 計測の前に、すべてのライブラリが同じフレームを描き、そのピクセルを PixiJS の出力と比べます。違うものを描いたライブラリは、その実行が失敗になります。
- 本当のコスト。 各フレームは GPU が描き終わるまで計測するので、画面のリフレッシュの陰に隠れるものはありません。
- 運に頼らない。 順番を入れ替えながら何ラウンドも回し、中央値とばらつきを報告します。
- すべて公開。 結果ファイルにはすべて、GPU、ブラウザ、CPU、さらには電源プランまで記録されます。もっと速いライブラリの設定方法を知っているなら、プルリクエストで変えられます。
詳しくは 自分をだまさずに 2D Web レンダラーをベンチマークする をどうぞ。
結果
NVIDIA RTX 5060 を積んだノート PC の Chrome で、各ライブラリが 60 fps で描ける動くスプライトの最大数です。
| シーン | spryte | PixiJS |
|---|---|---|
| 動くスプライト、一括 | 800,000 | 200,000 |
| サイズ・回転・色が変わるパーティクル、一括 | 400,000 | 100,000 |
| 動くスプライト、1 枚ずつ呼び出し | 100,000 | 50,000 |
| 何千ものテクスチャの帯で描くレトロな一人称迷路 | 敵 10,000 体 | 2,000 |
数は段階的に試しています(5 万、10 万、20 万…)。各数値は 60 fps を保てた最後の段階です。フレーム時間のグラフと生データは 2dbench の README にあります。迷路のシーンは Wolfenstein 3D 風のレイキャスター で、ほぼすべてのオブジェクトが毎フレーム変わるため、シーングラフにとって最も厳しいケースです。
起動時の JavaScript も、PixiJS の約 88 kB に対して spryte は約 10 kB(圧縮後)です。
これが意味しないこと
- PixiJS の出来が悪いわけではありません。 PixiJS ははるかに多くのことをこなします。フィルター、テキストレイアウト、インタラクション、プラグインのエコシステム。オブジェクトごとの設計は理にかなったトレードオフで、そのコストが表に出るのは、膨大な数のものが一斉に動くときだけです。
- 動かないシーンなら差はずっと小さくなります。 シーンの大部分が変わらないなら、シーングラフは処理を省けるので、差は縮まります。
- あなたのゲームが 4 倍速くなるわけではありません。 実際のゲームは、自分のロジック、物理、サウンドにも時間を使います。描画が速くなればその分の時間が戻ってきますが、ほかの部分が速くなるわけではありません。
- 今のところ、ほぼ 1 台のマシンでの結果です。 あるのは Windows と Linux のデスクトップ版 Chrome、それに iPhone 1 台だけです。Mac の Safari、Firefox、Android のスマートフォンはほとんどカバーできていません。
最後の点は、あなたにも手伝ってもらえます。
試してみよう
- 自分の端末でベンチマークを実行する: debuffjp.github.io/2dbench。数分で終わり、アカウントも不要で、あなたのスマートフォンやノート PC が公開結果に加わります。
- spryte を使う:
npm install spryte。MIT ライセンスで依存ライブラリはなく、WebGL2 が使えない環境では Canvas 2D にフォールバックします。私たちの 2D Web ゲーム向けツールキット debuff の中のレンダラーでもあります。 - 私たちが間違っていると証明する: PixiJS やほかのライブラリのもっと速い設定方法を知っているなら、2dbench にプルリクエストを送ってください。私たちのリードが縮んだら、それも公開します。
最初のプロファイルから 8 バイトのインスタンス形式に至るまでの、もっと長くて技術的なバージョンは spryte が速くなるまで にあります。