Skip to content

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:

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();

// 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 8spryte
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)
backgroundThe first argument to draw.begin(clear, width, height, scale)
Assets.load(url)Any image source: HTMLImageElement (after decode()), ImageBitmap, a canvas
new Sprite(texture) + propertiesdraw.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 + Particledraw.sprites(frame, positions, count, options, { scale, rotation, color }) with typed arrays
Container with x, y, rotation, scaledraw.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 maskdraw.clip(x, y, w, h, () => { ... })
RenderTexture + app.renderer.render({ container, target })draw.createTarget(w, h) and draw.into(target, () => { ... })
Filter / custom shadersdraw.createShader(source) and draw.withShader(shader, () => { ... })
zIndex, sortableChildrenSort your data, then draw in that order
eventMode, on("pointerdown"), hit areasYour 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:

ts
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 scale you pass to begin()).
  • 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.text draws a line with alignment and measure() 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):

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); // 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 ​

  1. Install: npm install spryte.
  2. Move one system first, usually particles or projectiles, using the overlay.
  3. Keep its state in typed arrays and draw it with draw.sprites.
  4. Measure with your own frame timings, or run 2dbench on the devices you care about.
  5. Move more if it pays off.

See also: spryte overview, API reference, Using spryte on its own, Particles and bullets with draw.sprites.