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:
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 });
}positionsholds x, y pairs.countsays 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 onedraw.sprite(). - Pass the same
Float32Arrayevery 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.
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 });scalemultiplies the sharedscaleX/scaleY.rotation(radians) adds to the sharedrotation.colormultiplies the sharedtintand alpha.- Give only the arrays you need. Missing ones default to scale 1, no rotation and white, and aren't uploaded.
- Colours are packed
0xRRGGBBAAin aUint32Array, and spryte unpacks them on the GPU, so you never reorder bytes in JavaScript. Remember the>>> 0when you build one, so it stays unsigned. countis 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, adraw.sprite()loop is simpler and batches well anyway. - Inside
withShader. Custom shaders run in the general sprite program, sosprites()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.