本文へスキップ

Phaser からの移行 ​

Phaser はゲームフレームワークです。シーン、ローダー、入力、物理、トゥイーン、オーディオ、カメラ、そしてレンダラーを持っています。spryte はレンダラーだけです。なので最初に考えるべきは、スプライトの移し方ではなく、Phaser のどの部分を置き換えるかです。このガイドでは描画側について、考え方、同じゲームの両方での書き方、対応表、そしてほかから持ってくる必要があるものを説明します。先に測りたい場合は、1つの演出で試してみてください。

置き換えるもの ​

Phaserspryte を使う場合
WebGL レンダラー、ゲームオブジェクト、カメラ(描画)、ブレンドモード、マスク、レンダーテクスチャ、シェーダーspryte
ゲームループ(game.loop、update(time, delta))自前の requestAnimationFrame ループ、または debuff の Game
シーンとシーンマネージャー自前の状態管理、または debuff のシーン
ローダー(this.load.image、アトラス、オーディオ)Image.decode()、createImageBitmap、または debuff のアセット
入力、Arcade / Matter 物理、トゥイーン、オーディオ、タイマー自前のコード、好きなライブラリ、または debuff のパッケージ

Arcade 物理やトゥイーン、シーンマネージャーに大きく頼っているゲームでは、全部を移すのはレンダラーの差し替えより大きな作業になります。重い部分(たいていは弾やパーティクル)から始めて、まず試してください。

考え方の違い ​

Phaser(リテインド)spryte(イミディエイト)
create() で一度 this.add.image(...) し、update() でプロパティを変える。毎フレーム、見えるものすべてについて draw.sprite(image, x, y, options) を呼ぶ。
シーンの表示リストが何を描くかを覚えている。ゲームの状態が唯一の情報源。同期すべきものはない。
オブジェクトを destroy() するか、Group と setActive(false) でプールする。描くのをやめる。プールするものはない。
描画順は表示リストと setDepth で決まる。描画順は呼び出し順。

得られるのはオブジェクトごとの処理の削減です。Phaser のゲームオブジェクトは毎フレーム更新、カリング、バッチ処理されますが、spryte はスプライトごとにいくつかの数値を書き込むだけです。その代わり、表示リストがやってくれていたこと(ヒットエリア、深度ソート、コンテナ)は自分で担うことになります。

コードの比較 ​

Phaser 4 です(同じコードが Phaser 3 でも動きます)。

ts
import Phaser from "phaser";

class Main extends Phaser.Scene {
  bunnies: Phaser.GameObjects.Image[] = [];

  preload() {
    this.load.image("bunny", "/bunny.png");
  }

  create() {
    for (let i = 0; i < 1000; i++) {
      this.bunnies.push(this.add.image(Math.random() * 320, Math.random() * 180, "bunny"));
    }
  }

  update() {
    for (const b of this.bunnies) b.rotation += 0.05;
  }
}

new Phaser.Game({
  type: Phaser.WEBGL,
  width: 320,
  height: 180,
  backgroundColor: "#111",
  pixelArt: true,
  scene: Main,
});

spryte:

ts
import { createRenderer } from "spryte";

const canvas = document.querySelector("canvas")!;
canvas.width = 320;
canvas.height = 180;
const draw = createRenderer(canvas, { smooth: false }); // pixelArt: true と同じ
draw.resize(canvas.width, canvas.height);

const bunny = new Image();
bunny.src = "/bunny.png";
await bunny.decode();

// 状態はただのデータ。描くまで spryte からは見えない。
const bunnies = Array.from({ length: 1000 }, () => ({ x: Math.random() * 320, y: Math.random() * 180, r: 0 }));
const look = { rotation: 0 }; // 使い回すオプションオブジェクト:描画でアロケーションは起きない

function frame() {
  draw.begin("#111", 320, 180, 1);
  for (const b of bunnies) {
    b.r += 0.05;
    look.rotation = b.r;
    draw.sprite(bunny, b.x, b.y, look); // デフォルトで中心揃え、Phaser の origin 0.5 と同じ
  }
  draw.end();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

対応表 ​

Phaser 4spryte
new Phaser.Game({ type: Phaser.WEBGL, width, height })createRenderer(canvas)。canvas のバッキングサイズが変わったら draw.resize(w, h)
pixelArt: truecreateRenderer(canvas, { smooth: false })、またはスプライトごとに smooth: false
backgroundColordraw.begin(clear, width, height, scale) の第1引数
scale: { mode: FIT, zoom }canvas のサイズは自分で決め、論理サイズと scale を draw.begin() に渡す
this.load.image(key, url)任意の画像ソース:HTMLImageElement(decode() の後)、ImageBitmap、canvas
this.add.image(x, y, key) / this.add.sprite(...)draw.sprite(image, x, y, { rotation, scaleX, scaleY, alpha, tint, flipX, flipY })
setOrigin(0, 0)origin: [0, 0](デフォルトは中心)
setTint(0xff00ff)tint: "#f0f"
setTint(0xffffff).setTintMode(Phaser.TintModes.FILL)(Phaser 3 では setTintFill)、被弾フラッシュ用flash: 1
アトラスのフレーム(this.add.image(x, y, "atlas", "frame"))Frame:{ image, x, y, w, h }。呼び出しごとに矩形が変わるなら1つのオブジェクトを使い回す
スプライトアニメーション(this.anims.create、play)自分の時計からフレームを選んで描く
Blitter、SpriteGPULayer、パーティクルエミッター(this.add.particles)型付き配列で draw.sprites(frame, positions, count, options, { scale, rotation, color })
x、y、rotation、scale を持つ Containerdraw.push()、translate、rotate、scale、描画、draw.pop()
this.cameras.main.scrollX、zoomdraw.push()、scale(zoom)、translate(-scrollX, -scrollY)、ワールドを描く、draw.pop()、その後 UI
Graphics:fillRect、fillCircle、lineBetweendraw.rect(x, y, w, h, color)、draw.circle(x, y, r, color)、draw.line(...)
Graphics:fillPoints、fillRoundedRect、strokePointspolygon(draw, points, color)、roundRect(draw, x, y, w, h, r, color, lineWidth?)、polyline(draw, points, width, color)、ring(...)
this.add.nineslice(...)nineSlice(draw, frame, x, y, w, h, insets)
tilePositionX 付きの this.add.tileSprite(...)tiled(draw, frame, x, y, w, h, { offsetX, offsetY, scale })
this.add.text(x, y, text, style) / bitmapTextdraw.text(text, x, y, { font, color, align, baseline })、または BitmapFont
setBlendMode(Phaser.BlendModes.ADD)(または MULTIPLY、SCREEN、ERASE)描画の前に draw.blend(mode)、後に "normal"
カメラのビューポート、または矩形のマスクdraw.clip(x, y, w, h, () => { ... })
filters.internal.addMask(shape)(Phaser 3 では createBitmapMask)mask(draw, () => { /* the shape */ }, () => { /* the content */ })
this.add.renderTexture(...)、draw(...)、render()draw.createTarget(w, h) と draw.into(target, () => { ... })
filters.internal.addBlur()、addColorMatrix()(Phaser 3 では preFX)filter(draw, [blur(4), grayscale()], () => { ... })
カスタムのレンダーノードや Shader(Phaser 3 ではパイプライン)draw.createShader(source) と draw.withShader(shader, () => { ... })
setDepth、this.children.sortデータをソートし、その順に描く
setInteractive()、on("pointerdown")ゲームの状態に対する自前のヒットテスト(下記参照)
destroy()、Group でのプール描くのをやめる。画像のピクセルが変わったら draw.invalidate(image)、レンダラーごと捨てるときは draw.dispose()

弾とパーティクル:一番の効果 ​

Phaser の弾のプール(使い回す Image の Group)やパーティクルエミッターは、draw.sprites の1回の呼び出しに対応します。状態を型付き配列で持てば、spryte はそれをそのままアップロードします。

ts
const MAX = 50_000;
const pos = new Float32Array(MAX * 2);    // シミュレーションが更新する x, y のペア
const scale = new Float32Array(MAX);
const rotation = new Float32Array(MAX);
const color = new Uint32Array(MAX);       // 0xRRGGBBAA
let live = 0;

// 毎フレーム、配列を更新した後に:
draw.sprites(spark, pos, live, {}, { scale, rotation, color });

WebGL2 では、パーティクルに対する JavaScript のループはまったくなく、プールするオブジェクトもありません。Phaser のエミッターは最初から多くのことをしてくれます(発生ゾーン、重力、寿命、イージング)。spryte では、その振る舞いは自分のシミュレーションの数行になります。

自分で書くもの ​

  • ループとタイミング。 spryte に update(time, delta) はありません。自前のデルタで requestAnimationFrame を使うか、固定タイムステップを持つ debuff の Game を使ってください。
  • ヒットテスト。 範囲をゲームの状態に持たせ、ポインターをそれと照合します。ポインターは自分の倍率(begin() に渡すのと同じ scale)で論理単位に変換してください。
  • カメラ。 カメラはワールドの描画を囲む push()、scale()、translate() です。追従、補間、シェイク、範囲制限は自分の計算で、それぞれ数行です。
  • 物理、トゥイーン、オーディオ。 spryte は関与しません。今のものを使い続けるか、debuff のパッケージを使ってください。
  • テキストのレイアウト。 draw.text は揃え位置を指定して1行を描き、measure() はそのサイズを教えてくれます。折り返しやリッチテキストは自分で実装します。

まず試してみる ​

書き換える前に、自分のゲームで spryte を半日で測れます。一番重い演出(弾やパーティクル)を、Phaser の canvas の上に重ねた背景の透ける canvas に spryte で描かせ、フレーム時間を比べます。これは評価のための方法で、そのまま使い続けることを勧める構成ではありません。transparent オプションには spryte 0.2 以降が必要です。

ts
// シーンの create() の中で
const game = this.game.canvas;
const overlay = document.createElement("canvas");
overlay.width = game.width;
overlay.height = game.height;
overlay.style.cssText = "position:absolute;inset:0;pointer-events:none;width:100%;height:100%";
game.parentElement!.append(overlay);

const draw = createRenderer(overlay, { smooth: false, transparent: true });
draw.resize(overlay.width, overlay.height);

// Phaser がフレームを描いた後に
this.events.on("postrender", () => {
  draw.begin("#0000", 320, 180, overlay.width / 320); // 透明でクリア:Phaser が透けて見える
  draw.sprites(bullet, bulletPositions, bulletCount);
  draw.end();
});

長く使う場合のトレードオフ:レンダラーが2つになるので、GPU コンテキストも2つになり、共有するテクスチャも2つずつ持つことになります。オーバーレイのサイズとカメラは Phaser のスケールマネージャーと常に揃える必要があります。また spryte は常に一番上に描くので、オーバーレイ上のものを Phaser のスプライトの後ろに回すことはできません。数字に納得できたら、レンダラー全体を移しましょう。

チェックリスト ​

  1. インストール:npm install spryte。
  2. 任意:一番重い演出でまず試して、フレーム時間を比べます。
  3. Phaser のループ、ローダー、入力、物理を何で置き換えるか決めます。自前のコード、ほかのライブラリ、または debuff です。
  4. 対応表に沿って、各シーンの表示リストを1つの描画関数に置き換えます。
  5. 大量の状態(弾、パーティクル)は型付き配列で持ち、draw.sprites で描きます。
  6. 自分のフレーム計測で測るか、気になるデバイスで 2dbench を実行します。

関連:spryte の概要、spryte vs Phaser のベンチマーク、PixiJS からの移行、API リファレンス、Phaser のゲームがカクつくのはなぜ?