本文へスキップ

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 ms400,000
spryte draw.sprites、単体のキャンバス1.3 ms400,000
Phaser SpriteGPULayer7.1 ms100,000
Phaser + spryte draw.sprite(スプライトごとに 1 回)6.9 ms100,000
Phaser Image17.8 ms20,000
PixiJS Sprite(基準)21.0 ms20,000

Bunny:位置のみ。

スプライト 5 万個フレーム60 fps で描ける最大数
Phaser + spryte draw.sprites0.8 ms1,600,000
Phaser SpriteGPULayer5.6 ms100,000
Phaser Blitter7.2 ms100,000
Phaser + spryte draw.sprite4.8 ms100,000
Phaser Image11.8 ms50,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 の記事には、全シーンを含む別の計測があります。

コード ​

ts
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 つの問題が見つかりました。どれも予想した場所ではありませんでした。

  1. 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 側の問題です。
  2. サンプラーオブジェクトはホストのフィルタリングを上書きします。 spryte はテクスチャのフィルタリングを WebGL2 のサンプラーオブジェクトで設定します。これは WebGL1 時代のコードが使うテクスチャごとのフィルターパラメータより優先されます。Phaser はサンプラーをバインドしないので、spryte が描いたあと、Phaser のニアレストフィルターのピクセルアートがぼやけました。640x360 のテストフレームで 30,000 ピクセルが変わりました。spryte はコンテキストを返すときにサンプラーを外すようにしました。
  3. シザーとステンシルはホストのものです。 最初の案では 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 を送ってください。結果は計測し直して公開します。