Skip to content

Particles and bullets with draw.sprites ​

A bullet-hell game might have 5,000 bullets on screen, and a spell effect 20,000 sparks. Calling draw.sprite() 20,000 times works, but every call does a little JavaScript: reading options, maths for the transform, writing an instance. draw.sprites() draws one image at many positions in a single call, and on WebGL2 it can do it with no JavaScript per sprite at all.

The basics ​

Keep your particles in typed arrays and hand spryte the positions:

ts
const MAX = 20_000;
const pos = new Float32Array(MAX * 2); // x0, y0, x1, y1, …
const vel = new Float32Array(MAX * 2);
let live = 0;

function update(dt: number) {
  for (let i = 0; i < live; i++) {
    pos[i * 2] += vel[i * 2] * dt;
    pos[i * 2 + 1] += vel[i * 2 + 1] * dt;
  }
}

function render(draw: Draw) {
  draw.sprites(spark, pos, live, { tint: "#ffd36b", alpha: 0.9 });
}
  • positions holds x, y pairs. count says how many to draw; omit it to draw them all.
  • The options (tint, alpha, scaleX, rotation, origin, flash, smooth…) apply to every copy, exactly as they would to one draw.sprite().
  • Pass the same Float32Array every frame. On WebGL2 spryte uploads it directly, so the only per-particle work is your own update loop.

Different per particle: each ​

Sparks that shrink, debris that spins, embers that fade: pass per-copy arrays as the fifth argument.

ts
const scale = new Float32Array(MAX);
const rotation = new Float32Array(MAX);
const color = new Uint32Array(MAX); // 0xRRGGBBAA

// e.g. fade out over a particle's life
color[i] = (0xffd36b00 | Math.round(255 * lifeLeft)) >>> 0;

draw.sprites(spark, pos, live, {}, { scale, rotation, color });
  • scale multiplies the shared scaleX/scaleY. rotation (radians) adds to the shared rotation. color multiplies the shared tint and alpha.
  • Give only the arrays you need. Missing ones default to scale 1, no rotation and white, and aren't uploaded.
  • Colours are packed 0xRRGGBBAA in a Uint32Array, and spryte unpacks them on the GPU, so you never reorder bytes in JavaScript. Remember the >>> 0 when you build one, so it stays unsigned.
  • count is clamped to the shortest array you pass, so a short array never makes spryte read garbage.

It draws exactly what sprite() would ​

draw.sprites(img, pos, n, o, each) draws the same pixels as calling draw.sprite() n times with the matching values, including pixel-art snapping (unrotated copies land on whole pixels). spryte's test suite checks this on a real GPU, pixel for pixel, on both the WebGL2 and Canvas 2D backends. So you can switch an effect from a loop to sprites() without it looking different.

What it costs ​

On our benchmark laptop, 200,000 sprites with every property changing every frame took 7.7 ms with draw.sprites and 28.9 ms with PixiJS's ParticleContainer (details on the benchmarks page). Position-only, draw.sprites held 60 fps at 800,000 sprites.

When to use draw.sprite instead ​

  • Different images. sprites() draws one frame. For a few hundred sprites from many textures, a draw.sprite() loop is simpler and batches well anyway.
  • Inside withShader. Custom shaders run in the general sprite program, so sprites() falls back to one quad per copy. It's still correct, just not the bulk speed.
  • On the Canvas 2D fallback, used only where WebGL2 isn't available, every copy is drawn individually, as any Canvas 2D code would.

Draw order ​

sprites() is one draw call, drawn after everything before it and before everything after it. Put each layer (smoke behind, sparks in front) in its own call, in the order you want.

Introducing spryte has the bigger picture, and the API reference has every option.