PixiJS からの移行
spryte は PixiJS のドロップイン置き換えではありません。PixiJS は表示オブジェクトのツリーを保持しますが、spryte は毎フレーム頼まれたものを描くだけで、何も保持しません。移行はたいていコードを削る作業になりますが、考え方は変わるので、このガイドでは2つの道筋を扱います。
- ホットパスだけを移す。 PixiJS のゲームはそのままに、重い部分(弾、パーティクル、群衆)を上に重ねた canvas で spryte に描かせます。まずはここから始めてください。
- レンダラー全体を移す。 ステージを描画関数に置き換えます。ほとんどのものが毎フレーム動くゲームなら、その価値があります。
考え方の違い
| PixiJS(リテインド) | spryte(イミディエイト) |
|---|---|
Sprite を一度作ってステージに追加し、時間とともにプロパティを変える。 | 毎フレーム、見えるものすべてについて draw.sprite(image, x, y, options) を呼ぶ。 |
| ステージが何を描くかを覚えている。 | ゲームの状態が唯一の情報源。同期すべきものはない。 |
| 不要になったオブジェクトを削除または破棄する。 | 描くのをやめる。 |
描画順はツリーと zIndex で決まる。 | 描画順は呼び出し順。 |
利点は 2dbench が計測しているとおりです。オブジェクトごとの管理処理がないので、スプライト1つのコストは、その数値を書き込むコストだけです。その代わり、シーングラフがやってくれていたこと(ヒットテスト、イベント、入れ子)は自分で担うことになります。
コードの比較
PixiJS 8:
ts
import { Application, Assets, Sprite } from "pixi.js";
const app = new Application();
await app.init({ width: 320, height: 180, background: "#111" });
document.body.append(app.canvas);
const texture = await Assets.load("/bunny.png");
const bunnies = [];
for (let i = 0; i < 1000; i++) {
const s = new Sprite(texture);
s.anchor.set(0.5);
s.x = Math.random() * 320;
s.y = Math.random() * 180;
app.stage.addChild(s);
bunnies.push(s);
}
app.ticker.add(() => {
for (const s of bunnies) s.rotation += 0.05;
});spryte:
ts
import { createRenderer } from "spryte";
const canvas = document.querySelector("canvas")!;
canvas.width = 320;
canvas.height = 180;
const draw = createRenderer(canvas);
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); // デフォルトで中心揃え、anchor 0.5 と同じ
}
draw.end();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);対応表
| PixiJS 8 | spryte |
|---|---|
new Application()、app.init({ ... }) | createRenderer(canvas, { smooth })。canvas のバッキングサイズが変わったら draw.resize(w, h) |
app.ticker.add(fn) | 自前の requestAnimationFrame ループ、またはエンジンのループ(spryte にティッカーはありません) |
background | draw.begin(clear, width, height, scale) の第1引数 |
Assets.load(url) | 任意の画像ソース:HTMLImageElement(decode() の後)、ImageBitmap、canvas |
new Sprite(texture) + プロパティ | draw.sprite(image, x, y, { rotation, scaleX, scaleY, alpha, tint, flipX, flipY }) |
sprite.anchor.set(0, 0) | origin: [0, 0](デフォルトは中心) |
texture.source.scaleMode = "nearest" | createRenderer(canvas, { smooth: false })、またはスプライトごとに smooth: false |
スプライトシートのフレーム(frame 付きの Texture) | Frame:{ image, x, y, w, h }。呼び出しごとに矩形が変わるなら1つのオブジェクトを使い回す |
ParticleContainer + Particle | 型付き配列で draw.sprites(frame, positions, count, options, { scale, rotation, color }) |
x、y、rotation、scale を持つ Container | draw.push()、translate、rotate、scale、描画、draw.pop() |
Graphics().rect(...).fill(c) / .circle(...) | draw.rect(x, y, w, h, color)、draw.circle(x, y, r, color)、draw.line(...) |
new Text({ text, style }) | draw.text(text, x, y, { font, color, align, baseline })、または BitmapFont |
sprite.blendMode = "add" | 描画の前に draw.blend("add")、後に "normal" |
矩形の mask | draw.clip(x, y, w, h, () => { ... }) |
RenderTexture + app.renderer.render({ container, target }) | draw.createTarget(w, h) と draw.into(target, () => { ... }) |
Filter / カスタムシェーダー | draw.createShader(source) と draw.withShader(shader, () => { ... }) |
zIndex、sortableChildren | データをソートし、その順に描く |
eventMode、on("pointerdown")、ヒットエリア | ゲームの状態に対する自前のヒットテスト(下記参照) |
destroy() | 描くのをやめる。画像のピクセルが変わったら draw.invalidate(image)、レンダラーごと捨てるときは draw.dispose() |
パーティクル:一番の効果
ParticleContainer は 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 のループはまったくありません。ベンチマークの差の大部分はここから生まれています。
自分で書くもの
- ヒットテスト。 範囲をゲームの状態に持たせ、ポインターをそれと照合します。ポインターは自分の倍率(
begin()に渡すのと同じscale)で論理単位に変換してください。 - 入れ子。 親子のトランスフォームは、
push()、translate()、子を描く、pop()です。深いシーンツリーに頼るゲームなら、その部分は PixiJS に残してください。 - テキストのレイアウト。
draw.textは揃え位置を指定して1行を描き、measure()はそのサイズを教えてくれます。折り返しやリッチテキストは自分で実装します。 - シェーダー以外のフィルター。 spryte にはフラグメントシェーダー、レンダーターゲット、加算ブレンドがあります。組み込みのブラーやフィルターのライブラリはありません。
PixiJS を残してホットパスを移す
すべてを移す必要はありません。PixiJS の canvas の上に背景が透ける2枚目の canvas を重ね、重い部分を spryte に描かせます(transparent オプションには spryte 0.2 以降が必要です)。
ts
const overlay = document.createElement("canvas");
overlay.width = app.canvas.width;
overlay.height = app.canvas.height;
overlay.style.cssText = "position:absolute;inset:0;pointer-events:none;width:100%;height:100%";
app.canvas.parentElement!.append(overlay);
const draw = createRenderer(overlay, { smooth: false, transparent: true });
draw.resize(overlay.width, overlay.height);
app.ticker.add(() => {
draw.begin("#0000", 320, 180, overlay.width / 320); // 透明でクリア:PixiJS が透けて見える
draw.sprites(bullet, bulletPositions, bulletCount);
draw.end();
});弾やパーティクルはたいてい一番上に描かれるものなので、ゲームの残りの部分はまったくそのままです。
チェックリスト
- インストール:
npm install spryte。 - まず1つのシステムを移します。たいていはパーティクルか弾で、オーバーレイを使います。
- その状態を型付き配列で持ち、
draw.spritesで描きます。 - 自分のフレーム計測で測るか、気になるデバイスで 2dbench を実行します。
- 効果があれば、さらに移します。
関連:spryte の概要、API リファレンス、spryte を単体で使う、draw.sprites でパーティクルと弾幕。