Phaser ゲームの中で spryte を使う
Phaser 4 のゲームは、今あるものをすべて残したまま、一番重い描画(弾、パーティクル、群れ)だけを spryte に任せられます。spryte は Phaser のフレームの中で、指定した深度に、同じ WebGL2 コンテキスト上で描きます。重ねるキャンバスも、テクスチャの二重持ちも不要で、Phaser のオブジェクトを spryte の描画の手前に置くこともできます。spryte 0.4 以降と Phaser 4(4.2.1 で検証)が必要です。
import Phaser from "phaser";
import { createPhaserContext, spryteExtern } from "spryte/phaser";
class Main extends Phaser.Scene {
create() {
// Phaser が深度 0 に背景を、深度 2 に HUD を描き…
this.add.image(400, 225, "background").setDepth(0);
this.add.text(12, 12, "score 0").setDepth(2);
// …その間に spryte が弾を描く。
const extern = this.add.extern().setDepth(1);
spryteExtern(extern, (draw) => {
draw.sprites(bullet, positions, count);
});
}
}
new Phaser.Game({
type: Phaser.WEBGL,
...createPhaserContext(), // WebGL2 コンテキスト。Phaser 自身は WebGL1 を作る
width: 800,
height: 450,
scene: Main,
});仕組み
createPhaserContext()は WebGL2 コンテキストを作り、ゲーム設定に展開する{ canvas, context }を返します(type: Phaser.WEBGLと一緒に使います)。Phaser はデフォルトでは WebGL1 コンテキストを作りますが、spryte には WebGL2 が必要で、Phaser は WebGL2 の上でも動きます。第 2 引数でコンテキストの属性を指定でき、デフォルトは Phaser 自身のもの(不透明、アンチエイリアスあり、premultiplied、ステンシルバッファあり)です。ゲームにすでにキャンバスがあるなら、第 1 引数に渡してください。spryteExtern(extern, render)は Extern のrenderを設定します。毎フレーム、Phaser の描画順の中でrender(draw)が呼ばれます。そのDrawの変換はすでに Phaser のもの、つまりカメラ(スクロール、ズーム、回転)と、Extern が入っている Container の分が適用済みです。座標は Phaser が使うものと同じで、draw.spritesには Phaser のエミッターならオブジェクトが必要だった型付き配列をそのまま渡せます。- spryte は、Phaser が想定していない状態を残しません。 Phaser は Extern のあとに自分の状態をリセットし、spryte は Phaser の描画コンテキストの下で描きます。つまり Phaser のフレームバッファへ、Phaser のシザーとステンシルの下で描きます。
Draw は最初のフレーム以降に存在します。spryteExtern は { draw, dispose } を返し、dispose は Extern が破棄されると自動で呼ばれるので、シーンの終了で spryte の GPU オブジェクトも解放されます。
そのまま効くもの
spryte は Phaser の描画コンテキストに、Phaser の状態の下で描くので、Phaser の機能がそのまま効きます。
- フィルター。
extern.enableFilters()とextern.filters.internal.addBlur(...)は spryte が描いたものにかかります。カメラのフィルターにも含まれます。 - カメラのビューポート。 複数のカメラ(ミニマップ、画面分割)があれば、spryte はそれぞれのカメラのビューポートの中に、そのカメラの変換で描きます。
Stencilマスク。 Extern より前のStencilゲームオブジェクトは、Phaser のオブジェクトと同じように spryte の描画をマスクします。- 深度。 Extern の
setDepthで、spryte の描画を表示リストのどこにでも置けます。深度が複数必要なら Extern を複数使います。それぞれにrenderがあります。
効かないもの:Extern 自身の alpha、tint、blendMode プロパティです。Phaser のドキュメントにあるとおり、Extern では使われません。透明度、色、ブレンドは draw の呼び出し側で指定してください。
シェーダーシムが必要な理由
Phaser のシェーダーは GLSL ES 1.00 で書かれています。WebGL2 コンテキストでは、Phaser 4.2.1 の 3 つの機能がコンパイルに失敗し、ゲームの描画が止まります。Gradient ゲームオブジェクト、noiseNormalMap を使うノイズオブジェクト、そしてレンダー設定の smoothPixelArt です。これらはスクリーン空間の微分(dFdx)を使っており、ES 1.00 では OES_standard_derivatives 拡張から来ますが、ES 3.00 には微分が組み込まれているため WebGL2 にはこの拡張がありません。
createPhaserContext() は shimShaders(gl) を組み込みます。これはコンテキストがコンパイルするすべての ES 1.00 シェーダーを ES 3.00 に変換します。そのため、これらの機能は WebGL1 と同じように描画されます(spryte のテストにおいてピクセル単位で確認済み)。自作の ES 1.00 シェーダーも同じように変換され、すでに #version 300 es を宣言しているシェーダーはそのまま通ります。変換では ES 3.00 で予約語になった識別子(sample、smooth、texture など)をリネームし、uniform と attribute の検索でも同じリネームを行います。コンテキストを自分で作りたい場合は、new Phaser.Game の前に shimShaders(gl) を呼んでください。
ヒント
- 一括の状態は型付き配列に。
draw.sprites(frame, positions, count, options, { scale, rotation, color })はそれらをそのままアップロードします。弾ごとの JavaScript も、プールするオブジェクトもありません。Phaser のエミッターは最初から多くのこと(放出ゾーン、重力、寿命)をしてくれるので、spryte ではその挙動は自前のシミュレーションを数行書くことになります。一括スプライトとパーティクルを参照してください。 - テクスチャは自由。 spryte は
HTMLImageElement、キャンバス、ImageBitmapを描けます。Phaser が読み込んだ画像ならthis.textures.get(key).getSourceImage()で取り出せます。 - 単位は Phaser のピクセル。
renderが呼ばれる時点で、描画コンテキストのサイズでのbegin(null, width, height, 1)は済んでいます。テキストは Phaser 自身と同じく、1 単位 1 ピクセルでラスタライズされます。 - レンダーターゲットとシェーダーも中で使えます。
draw.into、draw.createShader、withShaderはすべて Extern のスロット内で動きます。ターゲットとシェーダーはrenderの外で一度だけ作り、使い回してください。 - Phaser 3 の
Externはコールバックと状態の扱いが異なります。サポートしていません。
計測する
2dbench の particles シーン(すべてのプロパティが変化、スプライト 5 万個、RTX 5060 搭載ノート PC、5 ラウンド)で測りました。Phaser の Extern の中の spryte の draw.sprites は 1 フレーム 1.4 ms、Phaser の SpriteGPULayer は 7.1 ms でした。スプライトごとに呼ぶ draw.sprite は 6.9 ms、Image は 17.8 ms です。spryte 単体のキャンバスは 1.3 ms なので、Phaser の中にいるコストは 0.1 ms、計測の誤差の範囲です。生データ付きの数値は Phaser の中の spryte にあります。自分のゲームで測るには、Extern が描く場合と描かない場合のフレーム時間を比べるか、演出を 1 つ移して比較してください。
関連:レンダラー全体を移すなら Phaser からの移行、一括スプライトとパーティクル、API リファレンス。