Phaser の中の spryte:書き換えなしでパーティクルが 5 倍速く
Phaser 4 のゲームは、Phaser をそのまま残して、一番重い描画だけを spryte に任せられます。Extern ゲームオブジェクト 1 つが、Phaser の表示リストの中に spryte の場所を作ります。同じ WebGL2 コンテキスト上なので、弾やパーティクルは指定した深度に、Phaser のカメラ、フィルター、マスクの下で描かれます。2dbench の particles シーン(スプライト 5 万個)では、Phaser の中の spryte は 1 フレーム 1.4 ms、Phaser の SpriteGPULayer は 7.1 ms(5.1 倍)でした。60 fps で描けた数は 40 万個対 10 万個です。spryte 単体のキャンバスと比べたコストは 0.1 ms です。spryte 0.4 の spryte/phaser として入っています。ガイドは Phaser ゲームの中で spryte を使うです。
結果
RTX 5060 搭載ノート PC(Ryzen 7 260)、Chrome 155、AC 電源、シーンごとに交互実行で 5 ラウンド。シミュレーションから GPU 完了までのフレーム時間の中央値です。すべてのライブラリが同じピクセルを描くことを 2dbench のパリティゲートで確認しています。Phaser 4.2.1、パリティの基準として PixiJS 8.22.0。生データ:results/community/2026-10-22-rtx-5060-laptop-phaser-spryte-*.json。
Particles:すべてのプロパティが変化(位置、回転、拡大縮小、色、透明度)。
| スプライト 5 万個 | フレーム | 60 fps で描ける最大数 |
|---|---|---|
Phaser + spryte draw.sprites(1 回の呼び出し) | 1.4 ms | 400,000 |
spryte draw.sprites、単体のキャンバス | 1.3 ms | 400,000 |
Phaser SpriteGPULayer | 7.1 ms | 100,000 |
Phaser + spryte draw.sprite(スプライトごとに 1 回) | 6.9 ms | 100,000 |
Phaser Image | 17.8 ms | 20,000 |
PixiJS Sprite(基準) | 21.0 ms | 20,000 |
Bunny:位置のみ。
| スプライト 5 万個 | フレーム | 60 fps で描ける最大数 |
|---|---|---|
Phaser + spryte draw.sprites | 0.8 ms | 1,600,000 |
Phaser SpriteGPULayer | 5.6 ms | 100,000 |
Phaser Blitter | 7.2 ms | 100,000 |
Phaser + spryte draw.sprite | 4.8 ms | 100,000 |
Phaser Image | 11.8 ms | 50,000 |
20 万個でもバルクの差は保たれます。particles で 5.7 ms 対 25.0 ms(4.4 倍)、bunny で 2.6 ms 対 21.1 ms(8.1 倍)です。2 ms 未満のフレームは精度が低いので(理由)、Phaser の中の spryte と単体の spryte の 0.1 ms の差は「同じ」と読んでください。「60 fps で描ける最大数」は最大 2 倍刻みの段階から出しているので、下限です。これはこの計測の数値です。spryte vs Phaser の記事には、全シーンを含む別の計測があります。
コード
import Phaser from "phaser";
import { createPhaserContext, spryteExtern } from "spryte/phaser";
class Main extends Phaser.Scene {
create() {
this.add.image(400, 225, "background").setDepth(0);
this.add.text(12, 12, "score 0").setDepth(2);
// spryte は深度 1 に描く:背景の上、HUD の下。
spryteExtern(this.add.extern().setDepth(1), (draw) => {
draw.sprites(bullet, positions, count, {}, { scale, rotation, color });
});
}
}
new Phaser.Game({ type: Phaser.WEBGL, ...createPhaserContext(), width: 800, height: 450, scene: Main });createPhaserContext() は Phaser に WebGL2 コンテキストを渡します(Phaser 自身は WebGL1 を作ります)。spryteExtern は毎フレーム、Extern のスロットで render(draw) を呼びます。Phaser のカメラは適用済みなので、座標は Phaser のものです。弾、パーティクル、群れは型付き配列のまま、1 回の呼び出しで GPU に送られます。ゲームのほかの部分はそのままです。
コンテキストが 1 つであることの意味
2 つ目のレンダラーを足す一番わかりやすい方法は、透明なキャンバスを上に重ねることです。移行ガイドでも計測の方法として勧めていましたし、計測には使えます。ただしゲームには向きません。GPU コンテキストが 2 つ、共有テクスチャはすべて 2 部、オーバーレイは Phaser のスケールマネージャーに追従が必要で、spryte は常に一番上なので、描いたものを Phaser のスプライトの後ろに回せません。
Phaser の Extern はこのためにあります。呼び出しの前に Phaser はバッチをフラッシュしてテクスチャを外し、あとで自分のキャッシュした GL 状態を適用し直します。スロットの中で spryte は必要なものをバインドし、Phaser の描画コンテキスト(キャンバス、またはフィルターが描画中のフレームバッファ)に描いて、抜けます。重要なテストはピクセル比較です。Extern のあとに Phaser が描くものは、Extern が何も描かない場合と完全に一致する必要があります。ニアレストフィルターのテクスチャ、色、加算ブレンド、Graphics、Text、毎フレーム再アップロードされるキャンバステクスチャ、そのすべてです。Chrome、Firefox、WebKit で一致しました。
直す必要があった 3 つのこと
作る過程で 3 つの問題が見つかりました。どれも予想した場所ではありませんでした。
- Phaser 4.2.1 は WebGL2 コンテキストで壊れます。 Phaser は WebGL2 コンテキストを渡されても動く設計で、大半は問題なく動きます。しかし
Gradientゲームオブジェクト、noiseNormalMapを使うノイズオブジェクト、レンダー設定のsmoothPixelArtはコンパイルに失敗し、ゲームの描画が止まります。これらのシェーダーは GLSL ES 1.00 で、スクリーン空間の微分を使います。ES 1.00 では微分はOES_standard_derivatives拡張から来ますが、ES 3.00 には微分が組み込まれているため、WebGL2 はこの拡張を公開しません。createPhaserContextはシムを組み込み、コンパイル時にすべての ES 1.00 シェーダーを ES 3.00 に変換します。これでこれらの機能は WebGL1 と同じピクセルを描きます。3 つのエンジンすべてで再現するので、ブラウザではなく Phaser 側の問題です。 - サンプラーオブジェクトはホストのフィルタリングを上書きします。 spryte はテクスチャのフィルタリングを WebGL2 のサンプラーオブジェクトで設定します。これは WebGL1 時代のコードが使うテクスチャごとのフィルターパラメータより優先されます。Phaser はサンプラーをバインドしないので、spryte が描いたあと、Phaser のニアレストフィルターのピクセルアートがぼやけました。640x360 のテストフレームで 30,000 ピクセルが変わりました。spryte はコンテキストを返すときにサンプラーを外すようにしました。
- シザーとステンシルはホストのものです。 最初の案では spryte のフレーム開始時にシザーをリセットしていました。カメラが 2 つある画面(ミニマップ、画面分割)では、それがキャンバス全体に描いてしまいました。ビューポートの外に 60,800 ピクセルです。また Phaser のステンシルマスクには、描画コンテキストのステンシルテストの状態が必要で、マスクを書いた
Stencilオブジェクトが残した状態ではだめでした。ヘルパーは spryte が描く前に Phaser のDrawingContextの状態をすべて適用し、spryte はシザーとステンシルを見つけたままにして、自分のクリップはホストのシザー矩形と交差させます。ビューポート、Stencilマスク、フィルターはすべてピクセルチェックを通っています。
4 つ目は小さいものでした。Phaser の calcMatrix にはスキュー(不均一に拡大縮小した Container の中の回転した子)を含むことがあり、平行移動、回転、拡大縮小では表せません。spryte 0.4 は draw.transform(a, b, c, d, e, f) を追加し、ヘルパーはこれを使います。近似ではテストフレームに 134 ピクセルの誤りが残り、正確な行列では 0 でした。
この記事が示していないこと
ノート PC の GPU 1 つ、ブラウザのバージョン 1 つです。正しさのチェックには Playwright の Firefox と WebKit ビルドを使っており、Safari 自身の GPU パスではありません。Phaser 3 の Extern はコールバックと状態の扱いが異なり、サポートしていません。計測には npm リリース前の debuff リポジトリにある spryte 0.4 のコードを使ったため、結果ファイルのバージョンは 0.3.0 と記録されています。また、シーンはスプライトだけを描きます。Phaser 自身のオブジェクトが大半を占めるフレームでは、spryte に任せた分だけ速くなります。
自分のゲームで試すには、npm install spryte のあと Phaser ゲームの中で spryte を使うを読んでください。2dbench の Phaser の設定をもっと速くできるなら、PR を送ってください。結果は計測し直して公開します。