Phaser からの移行
Phaser はゲームフレームワークです。シーン、ローダー、入力、物理、トゥイーン、オーディオ、カメラ、そしてレンダラーを持っています。spryte はレンダラーだけです。なので最初に考えるべきは、スプライトの移し方ではなく、Phaser のどの部分を置き換えるかです。このガイドでは描画側について、考え方、同じゲームの両方での書き方、対応表、そしてほかから持ってくる必要があるものを説明します。先に測りたい場合は、1つの演出で試してみてください。
置き換えるもの
| Phaser | spryte を使う場合 |
|---|---|
| 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 でも動きます)。
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:
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 4 | spryte |
|---|---|
new Phaser.Game({ type: Phaser.WEBGL, width, height }) | createRenderer(canvas)。canvas のバッキングサイズが変わったら draw.resize(w, h) |
pixelArt: true | createRenderer(canvas, { smooth: false })、またはスプライトごとに smooth: false |
backgroundColor | draw.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 を持つ Container | draw.push()、translate、rotate、scale、描画、draw.pop() |
this.cameras.main.scrollX、zoom | draw.push()、scale(zoom)、translate(-scrollX, -scrollY)、ワールドを描く、draw.pop()、その後 UI |
Graphics:fillRect、fillCircle、lineBetween | draw.rect(x, y, w, h, color)、draw.circle(x, y, r, color)、draw.line(...) |
Graphics:fillPoints、fillRoundedRect、strokePoints | polygon(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) / bitmapText | draw.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 はそれをそのままアップロードします。
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 以降が必要です。
// シーンの 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 のスプライトの後ろに回すことはできません。数字に納得できたら、レンダラー全体を移しましょう。
チェックリスト
- インストール:
npm install spryte。 - 任意:一番重い演出でまず試して、フレーム時間を比べます。
- Phaser のループ、ローダー、入力、物理を何で置き換えるか決めます。自前のコード、ほかのライブラリ、または debuff です。
- 対応表に沿って、各シーンの表示リストを1つの描画関数に置き換えます。
- 大量の状態(弾、パーティクル)は型付き配列で持ち、
draw.spritesで描きます。 - 自分のフレーム計測で測るか、気になるデバイスで 2dbench を実行します。
関連:spryte の概要、spryte vs Phaser のベンチマーク、PixiJS からの移行、API リファレンス、Phaser のゲームがカクつくのはなぜ?