Migrating from PixiJS
spryte isn't a drop-in replacement for PixiJS. PixiJS keeps a tree of display objects; spryte draws what you ask for each frame and keeps nothing. Moving over is usually a matter of deleting code, but it is a change of model, so this guide covers both routes:
- Move the hot path only. Keep your PixiJS game and draw the expensive part (bullets, particles, a crowd) with spryte on a canvas layered on top. Start here.
- Move the whole renderer. Replace the stage with a draw function. Worth it for games where most things move every frame.
The mental model
| PixiJS (retained) | spryte (immediate) |
|---|---|
Create a Sprite once, add it to the stage, change its properties over time. | Each frame, call draw.sprite(image, x, y, options) for everything visible. |
| The stage remembers what to draw. | Your game state is the only source of truth. Nothing to keep in sync. |
| Remove or destroy objects you no longer need. | Stop drawing it. |
Draw order comes from the tree and zIndex. | Draw order is call order. |
The benefit is the one 2dbench measures: there's no per-object bookkeeping, so the cost of a sprite is the cost of writing its numbers. The trade is that anything the scene graph did for you (hit-testing, events, nesting) is now yours.
Side by side
PixiJS 8:
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:
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();
// Your state is plain data. spryte never sees it until you draw.
const bunnies = Array.from({ length: 1000 }, () => ({ x: Math.random() * 320, y: Math.random() * 180, r: 0 }));
const look = { rotation: 0 }; // one reused options object: drawing allocates nothing
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); // centred by default, like anchor 0.5
}
draw.end();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);Mapping table
| PixiJS 8 | spryte |
|---|---|
new Application(), app.init({ ... }) | createRenderer(canvas, { smooth }), then draw.resize(w, h) when the canvas's backing size changes |
app.ticker.add(fn) | Your own requestAnimationFrame loop, or your engine's (spryte has no ticker) |
background | The first argument to draw.begin(clear, width, height, scale) |
Assets.load(url) | Any image source: HTMLImageElement (after decode()), ImageBitmap, a canvas |
new Sprite(texture) + properties | draw.sprite(image, x, y, { rotation, scaleX, scaleY, alpha, tint, flipX, flipY }) |
sprite.anchor.set(0, 0) | origin: [0, 0] (default is the centre) |
texture.source.scaleMode = "nearest" | createRenderer(canvas, { smooth: false }), or smooth: false per sprite |
A spritesheet frame (Texture with a frame) | A Frame: { image, x, y, w, h }. Reuse one object if the rectangle changes per call |
ParticleContainer + Particle | draw.sprites(frame, positions, count, options, { scale, rotation, color }) with typed arrays |
Container with x, y, rotation, scale | draw.push(), translate, rotate, scale, draws, 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 }), or a BitmapFont |
sprite.blendMode = "add" | draw.blend("add") before the draws, "normal" after |
A rectangular mask | draw.clip(x, y, w, h, () => { ... }) |
RenderTexture + app.renderer.render({ container, target }) | draw.createTarget(w, h) and draw.into(target, () => { ... }) |
Filter / custom shaders | draw.createShader(source) and draw.withShader(shader, () => { ... }) |
zIndex, sortableChildren | Sort your data, then draw in that order |
eventMode, on("pointerdown"), hit areas | Your own hit-testing on your game state (see below) |
destroy() | Stop drawing it. draw.invalidate(image) if an image's pixels changed; draw.dispose() when the whole renderer goes |
Particles: the biggest win
A ParticleContainer maps to one draw.sprites call. Keep particle state in typed arrays, and spryte uploads them as they are:
const MAX = 50_000;
const pos = new Float32Array(MAX * 2); // x, y pairs your simulation updates
const scale = new Float32Array(MAX);
const rotation = new Float32Array(MAX);
const color = new Uint32Array(MAX); // 0xRRGGBBAA
let live = 0;
// each frame, after updating the arrays:
draw.sprites(spark, pos, live, {}, { scale, rotation, color });On WebGL2 there's no JavaScript loop over the particles at all. That's where most of the gap in the benchmarks comes from.
What you'll write yourself
- Hit-testing. Keep bounds in your game state and test the pointer against them. Convert the pointer to logical units with your own scale (the same
scaleyou pass tobegin()). - Nesting. A parent-child transform is
push(),translate(), draw the children,pop(). If your game leans on deep scene trees, keep those parts in PixiJS. - Text layout.
draw.textdraws a line with alignment andmeasure()tells you its size. Word wrap and rich text are up to you. - Filters beyond a shader. spryte has fragment shaders, render targets and additive blending. There's no built-in blur or filter library.
Keep PixiJS, move the hot path
You don't have to move everything. Put a second, see-through canvas over PixiJS's and let spryte draw the part that's expensive (the transparent option needs spryte 0.2 or later):
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); // transparent clear: PixiJS shows through
draw.sprites(bullet, bulletPositions, bulletCount);
draw.end();
});Bullets and particles usually draw on top anyway, and the rest of the game stays exactly as it was.
Checklist
- Install:
npm install spryte. - Move one system first, usually particles or projectiles, using the overlay.
- Keep its state in typed arrays and draw it with
draw.sprites. - Measure with your own frame timings, or run 2dbench on the devices you care about.
- Move more if it pays off.
See also: spryte overview, API reference, Using spryte on its own, Particles and bullets with draw.sprites.