本文へスキップ

spryte の内側:そのレンダラーは PixiJS とどう違うのか ​

spryte と PixiJS 8 は、どちらも WebGL で 2D スプライトを描き、1 回の描画呼び出しで多数のテクスチャをバッチし、乗算済みアルファを使います。共通する機能については同じピクセルを描きます。ところが、その下では正反対の賭けの上に成り立っています。PixiJS はシーングラフを持ち、スプライトごとに 4 つの頂点を JavaScript で組み立てます。spryte はイミディエイトモードで、すべての描画が1つの GPU インスタンスであり、一括パスではスプライトごとの JavaScript を一切使わずに 1 スプライトあたり 8 バイトをアップロードします。この記事では、両ライブラリのソース(spryte 0.1.2、PixiJS 8.22)から取ったバイト数とともに、そうした選択を順に見ていきます。それがフレーム時間でどれだけの価値を持つかは、spryte が速くなるまでをご覧ください。

sprytePixiJS 8
API モデルイミディエイトモード:毎フレーム draw.sprite(...)Container のリテインド型シーングラフ
GPU 上のスプライト共有クアッドの一つのインスタンス4 つの頂点と 6 つのインデックス
スプライトあたりのアップロード量52 バイト(draw.sprite)、8 バイト(draw.sprites、位置のみ)頂点 96 バイト + インデックス 24 バイト(Sprite)、動的属性ペアあたり 32 バイト(ParticleContainer)
角の位置を計算する場所頂点シェーダーJavaScript(packQuadAttributes)
描画呼び出しあたりのテクスチャ数8GPU のテクスチャユニット上限まで(一般的には 16)
バックエンドWebGL2 と、遅延読み込みの Canvas 2D フォールバックWebGL と WebGPU
起動時の JS(brotli)10.1 kB87.8 kB

1. イミディエイトモード対シーングラフ ​

PixiJS はリテインド型です。Container、Sprite、Graphics オブジェクトのツリーを組み立て、そのプロパティを変更すると、レンダラーがツリーをたどります。PixiJS 8 にはレンダーグループが加わり、描画命令のリストをキャッシュして、ツリーの構造が変わったとき(structureDidChange)だけ再構築します。ほとんどのオブジェクトがじっとしている UI やシーンにはよく合います。

spryte にはツリーがありません。ゲームは Canvas 2D と同じように、毎フレーム draw.sprite、draw.rect、draw.text を呼びます。spryte が保持するのは、安く再計算できないものだけです。アップロード済みのテクスチャ、ラスタライズ済みのテキスト、そして GPU バッファです。ゲームオブジェクトはゲーム自身のデータ構造の中にとどまります。スプライトごとに確保するオブジェクトも、伝播させる親のトランスフォームも、追跡するダーティフラグもなく、ホットパスでは一切メモリを確保しません。

代償は、動いていないものの処理を spryte が省けないことです。画面のほとんどが毎フレーム変わるゲームにとっては、これが正しいトレードオフです。静的な背景は、一度レンダーターゲットに描いて使い回すこともできます。

2. すべてはクアッド ​

spryte の内部では、どの描画呼び出しも1つのバックエンド操作になります。

ts
quad(matrix, source, srcRect, rgba, flash, smooth)

2×3 行列は単位正方形をシーンに写します。矩形は 1×1 の白いテクスチャを、円はキャッシュされた 64 px の円盤を、テキストはキャッシュされたラスター画像かビットマップフォントのグリフを使います。プリミティブが1つしかないので、クアッドに帰着する機能はすべて、ほかのあらゆるものとタダでバッチされます。テキスト、図形、スプライトが描画呼び出しを共有します。

PixiJS にはもっと豊富な描画対象(メッシュ、パスを分割して描く Graphics、タイリングスプライト)があり、それぞれ専用のパイプを持っています。そのぶん高機能ですがコードも増え、それが 9 倍のサイズ差の大きな部分を占めています。

3. インスタンス対頂点 ​

これが構造上いちばん大きな違いです。

PixiJS のデフォルトのバッチャーは、スプライトごとに 4 つの頂点を1つの大きなバッファに書き込みます。JavaScript は角ごとに、ワールドトランスフォームと角のローカル位置を掛け合わせ、6 ワードを書き込みます。位置(float 2 つ)、UV(float 2 つ)、パックした色、そしてパックしたテクスチャ ID と丸めフラグです。スプライトあたり 4 × 24 = 96 バイト、それに 6 つのインデックス(Uint32 で 24 バイト)が加わります。

spryte はインスタンス描画を使います。静的な 4 頂点のクアッドを一度バインドし、各スプライトは 52 バイトのインスタンス1つです。2×3 行列(float 6 つ)、UV 矩形(float 4 つ)、フラッシュとテクスチャユニット(float 2 つ)、そして RGBA8 の色です。行列をクアッドの角に適用するのは頂点シェーダーなので、JavaScript はスプライトごとに角のトランスフォームを 4 回ではなく行列を 1 回扱うだけで済み、アップロードするバイト数も半分以下になります。

4. スプライトごとの JavaScript がない一括パス ​

パーティクルシステムのスプライトのほとんどは、位置以外のすべてを共有しています。spryte の draw.sprites(frame, positions, count, options, each?) は、インスタンスがただの vec2、つまり 8 バイトの2つ目の GPU プログラムを使います。共通のトランスフォーム、UV、色合い、フラッシュはユニフォームになり、呼び出しごとに一度だけ送られます。ピクセルスナップは draw.sprite と同じルールに従い、頂点シェーダー内で行われます。手元にある Float32Array は、spryte が手を触れることなく、そのまま bufferData に渡されます。

オプションのコピーごとの配列(スケール、回転、パックした 0xRRGGBBAA の色)は別々のバッファです。渡さなかったものは定数の頂点属性のデフォルト値を読むので、完全に動的なパーティクルでも 8 + 4 + 4 + 4 = 20 バイトです。色はシェーダー内でビットシフトによって展開されるので、JavaScript がバイトを並べ替えることはありません。

PixiJS でこれに相当する ParticleContainer は、属性を静的バッファと動的バッファに分け、動的なものだけを再アップロードします。考え方としては正しいものです。ただし、その属性はやはり頂点ごとです。動的な位置はパーティクルあたり 4 × 8 = 32 バイトで、spryte は 8 バイトです。

5. バッチングと、バッチが途切れる条件 ​

どちらのライブラリも描画呼び出しごとに複数のテクスチャをバインドし、シェーダー内でスプライトごとに1つを選ぶので、別々の画像のスプライトでもバッチされます。PixiJS はバッチの大きさを GPU のテクスチャユニット上限に合わせます。spryte は(テクスチャ、フィルター)のペアを 8 つ使い、フィルターを WebGL2 のサンプラーオブジェクトで持つので、アップロードした一枚のテクスチャで、同じバッチの中のなめらかな描画とドット絵らしい描画の両方をまかなえます。

spryte のバッチがフラッシュされるのは、9 枚目の異なるテクスチャが来たとき、16,384 インスタンスのバッファが満杯になったとき、ブレンドやクリップが変わったとき、そしてフレームの終わりです。カスタムシェーダーでもインスタンス形式は崩れません。ユーザーの effect() は spryte のフラグメントヘッダーと main の間に差し込まれ、その前に #line 1 が置かれるので、コンパイルエラーはユーザー自身の行を指します。そうした描画も、ほかの描画と同じようにバッチされます。

6. 色の計算 ​

どちらのライブラリもアップロード時にアルファを乗算し、ONE, ONE_MINUS_SRC_ALPHA でブレンドします。spryte のフラグメントシェーダーは引用できるほど短いものです。

glsl
vec4 t = texture(u_tex, v_uv) * v_color;          // 色合い × アルファ、乗算済み
outColor = vec4(mix(t.rgb, vec3(t.a), flash), t.a); // フラッシュは白に向かって混ぜる

PixiJS 8.22 を相手にした 29 シーンのピクセル一致監査では、両者に同等の機能がある部分すべてで、spryte の WebGL2 の出力は完全に一致しました。部分的に透明なピクセルへの色合いも含めてです。

7. 必要なときだけ読み込まれる Canvas 2D フォールバック ​

PixiJS の2つ目のバックエンドは WebGPU です。spryte のそれは、WebGL2 が使えないブラウザや GPU のための Canvas 2D です。これは別のチャンクになっていて、使うときにだけ動的 import() で取得されるので、WebGL2 のユーザーがダウンロードすることはありません。

各クアッドは setTransform と drawImage になります。Canvas 2D には描画ごとの色合いがなく、合成モードでは部分的に透明なピクセルへの乗算済みの色合いを再現できません。そこで、色合いやフラッシュのかかったフレームは、シェーダーと同じ式を使って CPU 上で一度だけ色を塗り直し、ソース・フレーム・色・フラッシュごとにキャッシュします。色合いのない描画はその処理を丸ごと飛ばします。ブラウザでのチェックでは、色合いとフラッシュがかかった部分的に透明な同じスプライトを両方のバックエンドで描きます。描く先はキャンバス上とレンダーターゲットの中です。そのうえで、すべてのチャンネルが 2 レベル以内で一致することを求めています。

このフォールバックは正しさのための経路で、速さのためのものではありません。素の Canvas 2D と同じく、60 fps で保てるのは約 5,000 スプライトです。Canvas 2D は独自の方法でエッジをラスタライズするので、回転したスプライトのアンチエイリアスのかかったエッジは、1 ピクセル分のカバレッジだけ異なることがあります。

8. ほかのレンダラー ​

  • Phaser 3 は PixiJS と同じくリテインド型です。ディスプレイリストを持ち、WebGL パイプラインが CPU 上でマルチテクスチャの頂点バッチを組み立てます。
  • three.js でも 2D スプライトは描けますが、各 Sprite が独立したオブジェクトで、独立した描画呼び出しになります。数千のスプライトを扱うには InstancedMesh かカスタムシェーダーが必要で、それは spryte がデフォルトで行っているインスタンシングそのものです。
  • 素の Canvas 2D はスプライトごとに drawImage を 1 回呼ぶだけでバッチングがありません。ベンチマーク用のマシンで 60 fps の上限が 5,000 スプライト前後なのはそのためです。

この記事で示していないこと ​

  • リテインド型が勝つ場面。 命令をキャッシュするシーングラフは、ほとんど何も変化しない場面ではイミディエイトモードに勝つことがあります。私たちのベンチマークはすべてのスプライトをアニメーションさせるので、その場合は計測していません。
  • WebGPU。 spryte にはまだ WebGPU のバックエンドがありませんが、PixiJS にはあります。
  • spryte が省いている機能: メッシュ、ベクターパス、軸に沿ったクリップ以外のマスク、そしてほとんどのブレンドモード。それぞれ、ゲームが必要とし、かつよく使う経路にそのコストがかからないことをベンチマークで示せたときに追加します。

ソースを読んでみてください。spryte の WebGL2 バックエンドは丸ごと1つのファイル、webgl2.ts に収まっています。npm install spryte で試せます。